master
Harald Wolff 2019-10-04 16:36:17 +02:00
parent 53a9f198b5
commit 9a4f448a47
12 changed files with 716 additions and 366 deletions

188
IPPoolService.cs 100644
View File

@ -0,0 +1,188 @@
using System;
using ln.application;
using ln.application.service;
using System.Threading;
using ln.types.odb.ng;
using ln.types;
using System.Collections.Generic;
using System.Linq;
using ln.json;
using ln.types.odb.ng.storage.session;
namespace ln.provider
{
public class IPPoolService : ApplicationServiceBase
{
CoreService CoreService { get; set; }
ProviderApplication Application => (ProviderApplication)base.CurrentApplicationInterface;
RPC rpc;
SessionStorageContainer mapperSession;
Mapper mapper;
IPAllocation v6Root;
IPAllocation v4Root;
public IPPoolService()
:base("IPPoolService")
{
DependOnService<CoreService>();
rpc = new RPC(this);
}
public override void ServiceMain(IApplicationInterface applicationInterface)
{
CoreService = Dependency<CoreService>();
using (mapperSession = new SessionStorageContainer(CoreService.CoreStorageContainer))
{
mapper = new Mapper(mapperSession);
mapper.EnsureIndex<IPAllocation>("CIDR");
mapper.EnsureIndex<IPAllocation>("Pool");
Application.RPCContainer.Add("ippool", rpc);
v6Root = mapper.Load<IPAllocation>(Query.Equals<IPAllocation>("CIDR",IPv6.ANY)).FirstOrDefault();
v4Root = mapper.Load<IPAllocation>(Query.Equals<IPAllocation>("CIDR", IPv6.V4Space)).FirstOrDefault();
if (v6Root == null)
{
v6Root = new IPAllocation(IPv6.ANY, "Global IPv6 Space", null);
mapper.Save(v6Root);
}
if (v4Root == null)
{
v4Root = new IPAllocation(IPv6.V4Space, "Global IPv4 Space", v6Root.CIDR);
mapper.Save(v4Root);
}
Ready();
while (!StopRequested)
{
lock (Thread.CurrentThread)
{
Monitor.Wait(Thread.CurrentThread);
}
}
}
mapper = null;
Application.RPCContainer.Remove("ippool");
CoreService = null;
}
public IPAllocation[] GetAllocations()
{
return mapper.Load<IPAllocation>().ToArray();
}
public IPAllocation GetAllocation(IPv6 cidr)
{
return mapper.Load<IPAllocation>(Query.Equals<IPAllocation>("CIDR", cidr)).FirstOrDefault();
}
public IPAllocation[] GetPoolAllocations(IPv6 pool)
{
return mapper.Load<IPAllocation>(Query.Equals<IPAllocation>("Pool", pool)).ToArray();
}
public IPAllocation AllocateAny(IPv6 zone,int width,string usage)
{
return null;
}
public IPAllocation AllocateCIDR(IPv6 zone,IPv6 cidr,string usage,bool splitZones)
{
IPAllocation pool = GetAllocation(zone);
if (pool == null)
throw new KeyNotFoundException();
if (!pool.CIDR.Contains(cidr))
throw new ArgumentException(String.Format("{0} doesn't contain {1}", pool.CIDR.ToCIDR(), cidr.ToCIDR()));
if (splitZones)
{
IPAllocation allocation = pool;
while (!allocation.CIDR.Equals(cidr))
{
foreach (IPv6 splitCIDR in allocation.CIDR.Split(1))
{
if (splitCIDR.Contains(cidr))
{
IPAllocation split = GetAllocation(splitCIDR);
if (split == null)
{
split = new IPAllocation(splitCIDR,allocation.CIDR);
mapper.Save(split);
allocation = split;
break;
}
}
}
}
return allocation;
}
else
{
IPAllocation allocation = GetAllocation(cidr);
if (allocation != null)
throw new Exception("Allocation already present");
allocation = new IPAllocation(cidr, cidr.ToCIDR(), pool.CIDR);
allocation.AllocatedTo = usage;
mapper.Save(allocation);
return allocation;
}
return null;
}
class RPC
{
IPPoolService poolService;
public RPC(IPPoolService poolService)
{
this.poolService = poolService;
}
public IPAllocation[] GetAllocations() => poolService.GetAllocations();
public IPAllocation GetAllocation(IPv6 cidr) => poolService.GetAllocation(cidr);
public IPAllocation[] GetPoolAllocations(IPv6 pool) => poolService.GetPoolAllocations(pool);
public IPAllocation AllocateAny(IPv6 zone, int width, string usage) => poolService.AllocateAny(zone, width, usage);
public IPAllocation AllocateCIDR(IPv6 zone, IPv6 cidr, string usage, bool splitZones) => poolService.AllocateCIDR(zone, cidr, usage,splitZones);
}
public class IPAllocation
{
public IPv6 CIDR { get; }
public string Name { get; set; } = String.Empty;
public string AllocatedTo { get; set; } = String.Empty;
public IPv6 Pool { get; }
private IPAllocation(){}
public IPAllocation(IPv6 cidr): this(cidr, cidr.ToCIDR(), IPv6.ANY) { }
public IPAllocation(IPv6 cidr, IPv6 pool) : this(cidr, cidr.ToCIDR(), pool) { }
public IPAllocation(IPv6 cidr,String name,IPv6 pool)
{
Pool = pool;
CIDR = cidr;
Name = name;
}
}
}
}

View File

@ -10,6 +10,8 @@ using ln.types.odb.ng.storage;
using System.Security.Cryptography;
using System.Linq;
using ln.types;
using ln.types.odb.ng.storage.session;
using ln.types.odb.ng.storage.fs;
namespace ln.provider
{
public class ProviderApplication : Application
@ -28,12 +30,10 @@ namespace ln.provider
if (Directory.Exists("../../www"))
WWWPath = "../../www";
ServiceDefinition coreRPCService = new ServiceDefinition("ln.provider.CoreService");
coreRPCService.AutoStart = true;
ServiceDefinition coreRPCService = ServiceDefinition.From<CoreService>(true);
ServiceContainer.Add(coreRPCService);
ServiceContainer.Add(
coreRPCService
);
ServiceContainer.Add(ServiceDefinition.From<IPPoolService>(true));
}
public override void PrepareStart()
@ -66,7 +66,7 @@ namespace ln.provider
public ProviderApplication Application => (ProviderApplication)base.CurrentApplicationInterface;
public IStorageContainer CoreStorageContainer { get; private set; }
public Session CoreStorageSession { get; private set; }
public SessionStorageContainer CoreStorageSession { get; private set; }
public Mapper CoreStorageMapper { get; private set; }
public CoreService()
@ -79,7 +79,7 @@ namespace ln.provider
CoreStorageContainer = new FSStorageContainer("/var/cache/ln.provider");
CoreStorageContainer.Open();
CoreStorageSession = new Session(CoreStorageContainer);
CoreStorageSession = new SessionStorageContainer(CoreStorageContainer);
CoreStorageMapper = new Mapper(CoreStorageSession);
CoreStorageMapper.EnsureIndex<AuthenticatedUser>("ID");
@ -90,6 +90,8 @@ namespace ln.provider
Application.HttpServer.DefaultApplication = Application.WWWApplication;
Application.RPCContainer.Add("core",new RPC(Application));
Ready();
while (!StopRequested)
{
lock (this)

View File

@ -35,6 +35,7 @@
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="ProviderApplication.cs" />
<Compile Include="IPPoolService.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ln.types\ln.types.csproj">
@ -78,6 +79,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="www\style.css" />
<None Include="www\ln.provider.js" />
<None Include="www\page.layout.css" />
<None Include="www\ln.provider.pool.js" />
<None Include="www\ln.provider.pool.html" />
<None Include="www\ln.provider.components.js" />
</ItemGroup>
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -2,38 +2,68 @@
<html>
<head>
<meta charset="utf-8" />
<title>ln.provider</title>
<link rel="stylesheet" href="style.css">
<script type="text/javascript" src="vue.js"></script>
<script type="text/javascript" src="ln.application.js"></script>
<title>ln.provider Web Interface</title>
<link href="/style.css" rel="stylesheet" />
<link href="/page.layout.css" rel="stylesheet" />
<link href="/tables.layout.css" rel="stylesheet" />
<script type="text/javascript" src="/vue.js"></script>
<script type="text/javascript" src="/vue-router.js"></script>
<script type="text/javascript" src="/ln.tools.js"></script>
<script type="text/javascript" src="/ln.application.js"></script>
<script type="text/javascript" src="ln.provider.components.js"></script>
<script type="text/javascript" src="ln.provider.js"></script>
<script type="text/javascript" src="ln.provider.pool.js"></script>
</head>
<body>
<script type="text/javascript">
LN();
</script>
<div id="body">
<div id="header">
<div class="logo">
<div style="background-color: #2a7fff; color: white;">ln.</div><div style="background-color: white;">provider</div>
</div>
</div>
<div id="app">
<h1>{{ serverString }}</h1>
<div id="navbar">
<router-link
v-for="route in LNProvider.routes"
v-if="route.label"
v-bind:to="route.path"
>{{ route.label }}</router-link>
</div>
<div id="page">
<router-view
v-bind="{ LNP: LNP }"
></router-view>
</div>
<div id="footer" class="flex row">
<div id="ServerString" class="silver">{{ LNP.serverString }}</div>
<div class="grow"></div>
<div id="ServerTime" class="" style="margin-right: 12px;">{{ LNP.serverTime }}</div>
</div>
</div>
<script type="text/javascript">
var providerCore = {
serverString: "",
}
var LNP = new LNProvider();
LN().rpc("core","GetServerString",[],function(result,error){
document.title = result;
providerCore.serverString = result;
LNP
.initialize()
.then(function(){
const router = new VueRouter({
routes: LNProvider.routes,
});
new Vue({
el: "#body",
data: {
LNP,
},
router,
}).$mount("#body");
});
var app = new Vue({
el: "#app",
data: providerCore,
});
</script>
</body>
</html>

View File

@ -1,336 +0,0 @@
var LN = (function(){
var appInterface;
var defaultOptions = {
url: null,
};
class LNInterface {
constructor(opt){
var self = this;
this.options = {}
Object.assign(this.options,opt);
if (this.options.url == null)
this.options.url = this.constructURL();
this.rpcCallbacks = [];
this.rpcNextID = 1;
this.websocket = new WebSocket(this.options.url);
this.websocket.onerror = function(e){
alert("WebSocket caught error: " + e.date);
}
this.websocket.onmessage = function(e){
var j = JSON.parse(e.data);
if (j.state){
updateState(j.state);
} else if (j.id)
{
for (var n=0;n<self.rpcCallbacks.length;n++)
{
if (self.rpcCallbacks[n].id == j.id)
{
if (j.error)
{
console.log("RPCResult with error received: " + JSON.stringify(j.error));
}
self.rpcCallbacks[n].cbfn(j.result,j.error);
self.rpcCallbacks.splice(n,1);
return;
}
}
}
}
}
rpc(module,method,parameters,cbfn){
var rpcCall = {
module: module,
method: method,
parameters: parameters,
id: this.rpcNextID++,
};
if (this.websocket.readyState != 1)
{
setTimeout(function(){
LN().rpc(module,method,parameters,cbfn);
},250);
} else {
this.rpcCallbacks.push( { id: rpcCall.id, cbfn: cbfn } );
this.websocket.send(
JSON.stringify(rpcCall)
);
}
}
constructURL(){
var pageURI = window.location;
var scheme = pageURI.scheme == "https" ? "wss:" : "ws:";
var host = pageURI.host;
return scheme + "//" + host + "/socket";
}
}
return function(options){
if (!appInterface)
appInterface = new LNInterface(options);
return appInterface;
};
})();
/*
Object.values = function(o) {
var values = [];
for(var property in o) {
values.push(o[property]);
}
return values;
}
function encodeID( t )
{
return ("" + t).replace( /[\.\/]/g, "_");
}
var lagDetector = null;
function updateState(state)
{
try
{
if (lagDetector)
clearTimeout(lagDetector);
$("#ServerTime").text("ServerTime: " + moment(state.currentTime).format());
lagDetector = setTimeout(function(){
$("#ServerTime").text("Server lag detected");
}, 2000);
} catch (e)
{
$("#ServerTime").text("Server state unexpected!");
}
}
function SKYAPI(baseurl){
this.baseurl = baseurl;
this.refresh = []
this.websocket = new WebSocket("ws://localhost:8080/socket");
this.websocket.onerror = function(e){
alert("WebSocket Error: " + e);
}
this.websocket.onmessage = function(e){
var j = JSON.parse(e.data);
if (j.state){
updateState(j.state);
}
}
this.setBaseURL = function(url){ this.baseurl = url; }
this.addRefresh = function( rh, seconds = null ){ this.refresh.push( { interval: seconds ? seconds : 5, refresh: rh } ); }
this.get = function(page, json, handler = null){ return this.__request("GET", page, json, handler); }
this.post = function(page, json, handler = null){ return this.__request("POST", page, json, handler); }
this.put = function(page, json, handler = null){ return this.__request("PUT", page, json, handler); }
this.__request = function(method, page, json, handler = null){
if (page[0] == '/')
page = page.substr(1);
var x = new XMLHttpRequest();
if (handler != null)
{
x.onload = function(){
var responseText = x.responseText;
if (json && !content)
handler( JSON.parse( responseText ) );
else
handler( responseText );
}
}
x.open(method, this.baseurl + page);
if (json)
x.send(JSON.stringify(json));
else
x.send();
}
this.getJson = function(page, handler){
var j = function(t){
handler(JSON.parse(t));
};
return this.get( page, null, j );
}
this.call = function(endpoint,method,parameters = [], receiver = null){
var x = new XMLHttpRequest();
x.open("POST", this.baseurl + endpoint, (receiver != null));
x.setRequestHeader("content-type","application/json");
if (receiver)
{
x.onload = function(){ var r = JSON.parse(this.responseText).Result; receiver(r); }
x.onerror = function(){ receiver(false); }
}
var methodCall = {
"MethodName": method,
"Parameters": parameters
}
x.send(JSON.stringify(methodCall));
if (!receiver)
{
var result = JSON.parse(x.responseText);
if (result.Exception != null)
throw result.Exception;
return result.Result;
}
return x;
}
this.loadPage = function (page) {
if (page[0] == '/')
page = page.substr(1);
var x = new XMLHttpRequest();
x.open("GET", this.baseurl + page);
x.setRequestHeader("x-template-unframed","unframed");
x.onload = function()
{
$("#content").empty();
$("#content").append(this.responseText);
history.pushState(null, page, skyapi().baseurl + page);
}
this.refresh = []
x.send();
return false;
}
this.fireOnLoad = function(element){
if (element.onload != null)
{
element.onload();
}
for (var n=0;n<element.children.length;n++)
this.fireOnLoad(element.children[n]);
}
this.__refresh_index = 0;
this.UIRefresh = function(){
this.__refresh_index++;
for (var n=0;n<this.refresh.length;n++)
{
var r = this.refresh[n];
if ((this.__refresh_index % r.interval)==0)
r.refresh();
}
}
setInterval( function(){ skyapi().UIRefresh(); }, 1000 );
}
function showStatistics(stats)
{
try
{
$("#ServerTime").text("ServerTime: " + stats.ServerTime);
$("#indHttpServer").attr("state",stats.States.HttpServer);
$("#indManager").attr("state",stats.States.Manager);
$("#indCrawler").attr("state",stats.States.Crawler);
$("#indChecks").attr("state",stats.States.Checks);
$("#indDispatcher").attr("state",stats.States.Dispatcher);
$("#indHttpServer").attr("title",stats.States.HttpServer);
$("#indManager").attr("title",stats.States.Manager);
$("#indCrawler").attr("title",stats.States.Crawler);
$("#indChecks").attr("title",stats.States.Checks);
$("#indDispatcher").attr("title",stats.States.Dispatcher);
} catch (e)
{
$("#ServerTime").text("Server unreachable");
$("#indHttpServer").attr("state",3);
$("#indManager").attr("state",0);
$("#indCrawler").attr("state",0);
$("#indChecks").attr("state",0);
$("#indDispatcher").attr("state",0);
$("#indHttpServer").attr("title","UNKNOWN");
$("#indManager").attr("title","UNKNOWN");
$("#indCrawler").attr("title","UNKNOWN");
$("#indChecks").attr("title","UNKNOWN");
$("#indDispatcher").attr("title","UNKNOWN");
}
}
function updateStatistics()
{
try
{
var request = skyapi().call("api/management","GetStatistics",[],showStatistics);
} catch (e)
{
showStatistics(false);
}
}
var __skyapi = new SKYAPI("/");
function skyapi()
{
return __skyapi;
}
function ScaleSI(value)
{
if (value > 1000000000)
return ((value / 1000000000) | 0) + "G";
if (value > 1000000)
return ((value / 1000000) | 0) + "M";
if (value > 1000)
return ((value / 1000) | 0) + "k";
return value;
}
*/

View File

@ -0,0 +1,25 @@
Vue.component('toggle-pane',{
props: {
visible: {
type: Boolean,
default: false
},
label: String,
class: String,
},
data: function(){
return {
};
},
template: `
<div class="toggle-pane">
<button
@click="visible = !visible"
>{{ label }}</button>
<div
v-if="visible"
><slot></slot>
</div>
</div>
`,
});

92
www/ln.provider.js 100644
View File

@ -0,0 +1,92 @@
var LNProvider = (function(){
class LNProvider
{
constructor(options){
let self = this;
this.serverString = "N/A";
this.serverTime = "N/A";
this.lagDetector = null;
this.IPAllocations = [];
LN().option("wsError",(e)=>self.wsError(e));
LN().option("wsClose",(e)=>self.wsClose(e));
LN().option("wsUpdate",(e)=>self.wsUpdate(e));
LN().connect();
}
initialize(){
return Promise.all(LNProvider.initializers);
}
loadIPAllocations(){
let self = this;
console.log("loadIPAllocations()");
LN().rpc("ippool","GetAllocations",[],function(r,e){
self.IPAllocations = r;
console.log("IPA: " + JSON.stringify(r));
});
}
allocate(type,cidr,maskWidth,zone,usage,splitZone){
let self = this;
if (type == 0)
{
} else if (type == 1)
{
LN().rpc("ippool","AllocateCIDR",[zone,cidr,usage,splitZone],function(r,e){
if (e){
alert("Error: \n" + JSON.stringify(e));
} else {
self.IPAllocations.push(r);
}
});
}
}
wsUpdate(state)
{
try
{
if (this.lagDetector)
clearTimeout(this.lagDetector);
this.serverTime = moment(state.currentTime).format();
this.lagDetector = setTimeout(function(){
this.serverTime = "Server lag detected";
}, 2000);
} catch (e)
{
console.log(e);
}
}
wsError(e){
this.serverTime = "WebSocket: Error: " + JSON.stringify(e);
}
wsClose(e){
this.serverTime = "WebSocket: Connection lost";
setTimeout(function(){
LN().connect();
}, 2500 );
}
}
LNProvider.routes = [];
LNProvider.initializers = [];
return LNProvider;
})();

View File

@ -0,0 +1,74 @@
<div>
<h1>IP Pools</h1>
<toggle-pane
label="Allokationsparameter..."
:visible="true"
>
<div class="flex row">
<span>
<label for="allocFree">Freie Allokation</label>
<input type="radio" id="allocFree" v-model="allocationType" value="0"><br>
<label for="allocCIDR">CIDR Allokation</label>
<input type="radio" id="allocCIDR" v-model="allocationType" value="1"><br>
</span>
<fieldset
v-if="allocationType == 0"
>
<div>
<span>Maskenlänge:</span>
<span><input type="number" min="1" max="127" v-model="subnetWidth"></span>
</div>
<div>
<span>Netzbreite:</span>
<span><input type="number" min="1" max="127" v-model="allocationWidth"></span>
</div>
</fieldset>
<fieldset
v-if="allocationType == 1"
>
<div>
<span>Zielnetz:</span>
<span><input type="text" v-model="targetCIDR"></span>
</div>
<div>
<label for="independentAllocation">Unabhängige Allokation</label>
<input type="checkbox" id="independentAllocation" v-model="independentAllocation">
</div>
</fieldset>
<fieldset
>
<div>
<span>Verwendung:</span>
<span><input type="text" v-model="allocationUsage"></span>
</div>
</fieldset>
</div>
</toggle-pane>
<table>
<thead>
<tr>
<td>Aktionen</td>
<td>CIDR</td>
<td>Name</td>
<td>Pool</td>
<td>Verwendungsnachweis</td>
</tr>
</thead>
<tbody>
<tr
v-for="ipa in IPAllocations"
>
<td>
<button
@click="LNP.allocate(allocationType,targetCIDR,subnetWidth,ipa.CIDR,allocationUsage,!independentAllocation);"
>+</button>
</td>
<td>{{ ipa.CIDR }}</td>
<td>{{ ipa.Name }}</td>
<td>{{ ipa.Pool }}</td>
<td>{{ ipa.AllocatedTo }}</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,45 @@
LNProvider.initializers.push(
new Promise((resolve,reject)=>{
LN()
.load("/ln.provider.pool.html")
.then((template)=>{
LNProvider.routes.push(
{
path: "/ippool",
label: "IP Pool",
component: {
props: {
LNP: Object,
},
template: template,
data: function(){
return {
allocationWidth: 64,
targetCIDR: "",
independentAllocation: false,
allocationType: 0,
allocationUsage: "",
};
},
computed: {
IPAllocations: ()=>LNP.IPAllocations,
subnetWidth: {
get: function(){
return 128 - this.allocationWidth;
},
set: function(v){
this.allocationWidth = 128 - v;
},
},
},
beforeRouteEnter: function(to,from,next){
LNP.loadIPAllocations();
next();
},
},
}
);
resolve();
});
})
);

145
www/page.layout.css 100644
View File

@ -0,0 +1,145 @@

html {
padding: 0px;
margin: 0px;
}
body {
padding: 0px;
margin: 0px;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
display: block;
position: absolute;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
}
div {
margin: 0px;
padding: 0px;
flex-grow: 0;
}
div#body {
display: flex;
height: 100%;
width: 100%;
flex-direction: column;
}
#header {
top: 0px;
left: 0px;
right: 0px;
border-bottom: 1px solid black;
background-color: #bad6ff;
padding: 4px;
}
#header > div {
margin: 4px;
}
#navbar {
padding: 8px;
border-bottom: 1px solid black;
font-size: 16px;
color: #808080;
}
#navbar > a {
display: inline-block;
margin-left: 16px;
margin-right: 16px;
}
#navbar > a.router-link-active {
color: black;
}
#page {
padding: 16px;
flex-grow: 1;
overflow-y: scroll;
}
#page::after {
content: '';
display: block;
height: 16px;
width: 100%;
}
#footer {
height: 20px;
padding: 6px;
padding-left: 24px;
background-color: #bad6ff;
border-top: 1px solid black;
flex-grow: 0;
flex-shrink: 0;
}
.hidden {
visibility: hidden;
}
.p {
margin-bottom: 12px;
}
.DISABLED {
color: #D0D0D0;
}
fieldset {
display: inline-flex;
flex-direction: column;
border: none;
}
fieldset > div {
display: flex;
flex-direction: row;
width: 100%;
margin: 4px;
vertical-align: top;
}
fieldset > div > * {
flex-grow: 0;
}
fieldset > div > *:first-child {
flex-grow: 1;
}
.toggle-pane {
margin-bottom: 4px;
}
.toggle-pane > div {
display: block;
margin-top: 4px;
}
input {
height: 16px;
border: 1px solid #bad6ff;
border-radius: 3px;
padding: 4px;
}

View File

@ -25,3 +25,32 @@ div.app {
.logo {
display: inline-block;
border: 1px solid black;
font-size: 18px;
font-weight: 800;
flex-grow: 0;
flex-shrink: 0;
}
.logo > div {
display: inline-block;
padding: 4px;
}
.flex {
display: flex;
}
.flex.row {
flex-direction: row;
}
.flex.column {
flex-direction: column;
}
.flex > * {
margin: 4px;
}

View File

@ -0,0 +1,50 @@

table {
border-collapse: collapse;
width: 100%;
}
table tr {
}
table td {
padding: 4px;
border-bottom: 1px solid #D0D0D0;
vertical-align: top;
}
table > thead {
font-style: italic;
background-color: #bad6ff;
transition: background-color 1000ms;
}
table > thead tr {
}
table > thead td {
}
table > tbody tr {
}
table > tbody td {
}
table > tfoot tr {
}
table > tfoot td {
}
table > tbody > tr:hover > td {
border-top: 2px solid black;
border-bottom: 2px solid black;
}