CTL command history page

This adds a history page, it could be argued that it should be
a popup or an accordion on each sub page but this seemed
to work the best.

It may require the existing sqlite database to be delete

Change-Id: Id48d513ab2d25e60351bd34e8ba84db80d266d87
This commit is contained in:
Schiefelbein, Andrew 2020-11-04 14:34:29 -06:00
parent c13a04669b
commit c004a202dc
16 changed files with 539 additions and 10 deletions

View File

@ -72,6 +72,10 @@ export class AppComponent implements OnInit, WsReceiver {
displayName: 'Secret',
route: 'ctl/secret',
iconName: 'security'
}, {
displayName: 'History',
route: 'ctl/history',
iconName: 'history'
}]
}, {
displayName: 'Dashboards',

View File

@ -21,6 +21,7 @@ import { DocumentComponent } from './document/document.component';
import { ImageComponent } from './image/image.component';
import { PhaseComponent } from './phase/phase.component';
import { SecretComponent } from './secret/secret.component';
import { HistoryComponent } from './history/history.component';
import { WsConstants } from 'src/services/ws/ws.models';
import { AuthGuard } from 'src/services/auth-guard/auth-guard.service';
@ -52,6 +53,10 @@ const routes: Routes = [{
path: WsConstants.SECRET,
canActivate: [AuthGuard],
component: SecretComponent,
}, {
path: WsConstants.HISTORY,
canActivate: [AuthGuard],
component: HistoryComponent,
}];
@NgModule({

View File

@ -22,6 +22,7 @@ import { CtlRoutingModule } from './ctl-routing.module';
import { PhaseModule } from './phase/phase.module';
import { SecretModule } from './secret/secret.module';
import { ConfigModule } from './config/config.module';
import { HistoryModule } from './history/history.module';
import { CommonModule } from '@angular/common';
@NgModule({
@ -34,7 +35,8 @@ import { CommonModule } from '@angular/common';
DocumentModule,
BaremetalModule,
PhaseModule,
SecretModule
SecretModule,
HistoryModule
],
declarations: [CtlComponent],
providers: []

View File

@ -0,0 +1,21 @@
/*
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
*/
.center {
text-align: center;
}
table {
width: 100%;
}

View File

@ -0,0 +1,77 @@
<h1>Airship CTL Command History</h1>
<div class="container">
<table>
<tr>
<td>
<b>Command History:&nbsp;&nbsp;</b>
<select id="displaySelect" (change)="displayChange($event.target.value)">
<option value="baremetal">Baremetal</option>
<option value="cluster">Cluster</option>
<option value="config">Config</option>
<option value="document">Document</option>
<option value="image">Image</option>
<option value="phase">Phase</option>
<option value="secret">Secret</option>
</select>
&nbsp;&nbsp;<button type="submit" id="refreshButton" (click)="refresh()">Refresh</button>
</td>
</tr>
</table>
<br>
<div id="HistoryFound" hidden>
<mat-form-field>
<mat-label>Filter</mat-label>
<input id="filterInput" matInput (keyup)="applyFilter($event)" placeholder="Ex. set phaser to stun" #input>
</mat-form-field>
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="subComponent">
<th mat-header-cell *matHeaderCellDef mat-sort-header> subComponent </th>
<td mat-cell *matCellDef="let row"> {{row.subComponent}} </td>
</ng-container>
<ng-container matColumnDef="user">
<th mat-header-cell *matHeaderCellDef mat-sort-header> user </th>
<td mat-cell *matCellDef="let row"> {{row.user}} </td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef mat-sort-header> type </th>
<td mat-cell *matCellDef="let row"> {{row.type}} </td>
</ng-container>
<ng-container matColumnDef="target">
<th mat-header-cell *matHeaderCellDef mat-sort-header> target </th>
<td mat-cell *matCellDef="let row"> {{row.target}} </td>
</ng-container>
<ng-container matColumnDef="success">
<th mat-header-cell *matHeaderCellDef mat-sort-header> success </th>
<td mat-cell *matCellDef="let row"> {{row.success}} </td>
</ng-container>
<ng-container matColumnDef="started">
<th mat-header-cell *matHeaderCellDef mat-sort-header> started </th>
<td mat-cell *matCellDef="let row"> {{row.started}} </td>
</ng-container>
<ng-container matColumnDef="elapsed">
<th mat-header-cell *matHeaderCellDef mat-sort-header> elapsed </th>
<td mat-cell *matCellDef="let row"> {{row.elapsed}} </td>
</ng-container>
<ng-container matColumnDef="stopped">
<th mat-header-cell *matHeaderCellDef mat-sort-header> stopped </th>
<td mat-cell *matCellDef="let row"> {{row.stopped}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<!-- Row shown when there is no matching data. -->
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" colspan="4">No data matching the filter "{{input.value}}"</td>
</tr>
</table>
<mat-paginator [pageSizeOptions]="[5, 10, 25, 50, 100]" [pageSize]=10></mat-paginator>
</div>
</div>
<div id="LoadingHistory" class="center">
<h2>Loading history for {{selectedHistory}} please wait...</h2>
</div>
<div id="HistoryNotFound" class="center" hidden>
<h2>No command history data is available for {{selectedHistory}}</h2>
</div>
</div>

View File

@ -0,0 +1,66 @@
/*
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
*/
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HistoryComponent } from './history.component';
import { MatTableModule } from '@angular/material/table';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatPaginatorModule } from '@angular/material/paginator';
import { ToastrModule } from 'ngx-toastr';
import { MatSortModule } from '@angular/material/sort';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
describe('HistoryComponent', () => {
const component: HistoryComponent = null;
// let fixture: ComponentFixture<BaremetalComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
MatExpansionModule,
MatCardModule,
MatDividerModule,
MatButtonModule,
MatIconModule,
MatCheckboxModule,
MatFormFieldModule,
MatTableModule,
MatPaginatorModule,
MatInputModule,
MatSortModule,
ToastrModule.forRoot()
],
declarations: [
HistoryComponent
]
})
.compileComponents();
}));
// beforeEach(() => {
// fixture = TestBed.createComponent(BaremetalComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
it('should create', () => {
expect(component).toBeFalsy();
});
});

View File

@ -0,0 +1,142 @@
/*
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
*/
import { Component, OnInit, ViewChild } from '@angular/core';
import { WsService } from 'src/services/ws/ws.service';
import { WsMessage, WsReceiver, WsConstants } from 'src/services/ws/ws.models';
import { Log } from 'src/services/log/log.service';
import { LogMessage } from 'src/services/log/log-message';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { StatData } from './history.models';
@Component({
selector: 'app-bare-metal',
templateUrl: './history.component.html',
styleUrls: ['./history.component.css']
})
export class HistoryComponent implements WsReceiver, OnInit {
className = this.constructor.name;
type = WsConstants.CTL;
component = WsConstants.HISTORY;
selectedHistory = 'baremetal';
displayedColumns: string[] = ['subComponent', 'user', 'type', 'target', 'success', 'started', 'elapsed', 'stopped'];
dataSources: Map<string, MatTableDataSource<StatData>> = new Map();
dataSource: MatTableDataSource<StatData> = new MatTableDataSource();
@ViewChild(MatSort) sort: MatSort;
@ViewChild(MatPaginator) paginator: MatPaginator;
constructor(private websocketService: WsService) {
this.websocketService.registerFunctions(this);
}
async receiver(message: WsMessage): Promise<void> {
if (message.hasOwnProperty(WsConstants.ERROR)) {
this.websocketService.printIfToast(message);
} else {
switch (message.subComponent) {
case WsConstants.GET_DEFAULTS:
this.pushData(message.data);
break;
default:
Log.Error(new LogMessage('History message sub component not handled', this.className, message));
break;
}
}
}
ngOnInit(): void {
this.refresh();
}
// Filters the table based on the user input
// taken partly from the example: https://material.angular.io/components/table/overview
applyFilter(event: Event): void {
// get the filter value
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
if (this.dataSource.paginator) {
this.dataSource.paginator.firstPage();
}
}
// hide / show tables based on what's selected
displayChange(displaying): void {
// tag the replace string with the current context
this.selectedHistory = displaying;
// clear filters if they exist
const filter = (document.getElementById('filterInput') as HTMLInputElement);
if (filter.value.length > 0) {
filter.value = '';
this.dataSource.filter = '';
}
// if we have data show the table otherwise show nothing. Do you hear me Lebowski? NOTHING!
if (displaying in this.dataSources) {
this.dataSource = this.dataSources[displaying];
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
document.getElementById('HistoryFound').removeAttribute('hidden');
document.getElementById('LoadingHistory').setAttribute('hidden', 'true');
document.getElementById('HistoryNotFound').setAttribute('hidden', 'true');
} else {
document.getElementById('HistoryFound').setAttribute('hidden', 'true');
document.getElementById('LoadingHistory').setAttribute('hidden', 'true');
document.getElementById('HistoryNotFound').removeAttribute('hidden');
}
}
refresh(): void {
document.getElementById('HistoryFound').setAttribute('hidden', 'true');
document.getElementById('LoadingHistory').removeAttribute('hidden');
document.getElementById('HistoryNotFound').setAttribute('hidden', 'true');
const message = new WsMessage(this.type, this.component, WsConstants.GET_DEFAULTS);
Log.Debug(new LogMessage('Attempting to ask for node data', this.className, message));
this.websocketService.sendMessage(message);
}
// extract the data structure sent from the backend & render it to the table
private pushData(data): void {
Object.keys(data).forEach(key => {
const recordSet: StatData[] = new Array();
data[key].forEach(record => {
// we do it this way instead of a straight convert because we want to format dates and success / fail messages
// it could be argued that this should be done on the backend
recordSet.push({
subComponent: record.SubComponent,
user: record.User,
type: record.ActionType,
target: record.Target,
success: record.Success ? 'Succeeded' : 'Failed',
started: new Date(record.Started).toString(),
elapsed: record.Elapsed + (record.Elapsed < 1000 ? ' ms' : ' seconds'),
stopped: new Date(record.Stopped).toString()
});
});
this.dataSources[key] = new MatTableDataSource(recordSet);
});
const displaying = (document.getElementById('displaySelect') as HTMLInputElement).value;
this.displayChange(displaying);
}
}

View File

@ -0,0 +1,25 @@
/*
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
*/
// used to populate the stat data
export interface StatData {
subComponent: string;
user: string;
type: string;
target: string;
success: string;
started: string;
elapsed: string;
stopped: string;
}

View File

@ -0,0 +1,41 @@
/*
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
*/
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { HistoryComponent } from './history.component';
import { ToastrModule } from 'ngx-toastr';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTableModule } from '@angular/material/table';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatExpansionModule } from '@angular/material/expansion';
@NgModule({
imports: [
MatCheckboxModule,
MatFormFieldModule,
MatTableModule,
MatPaginatorModule,
MatInputModule,
MatSortModule,
MatExpansionModule,
ToastrModule.forRoot()
],
declarations: [
HistoryComponent
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HistoryModule { }

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -33,5 +33,6 @@ export enum Icons {
check_circle = 'check_circle',
close = 'close',
lock = 'lock',
lock_open = 'lock_open'
lock_open = 'lock_open',
history = 'history'
}

View File

@ -105,6 +105,7 @@ export class WsConstants {
public static readonly AUTH = 'auth';
public static readonly AUTHENTICATE = 'authenticate';
public static readonly DENIED = 'denied';
public static readonly HISTORY = 'history';
public static readonly INITIALIZE = 'initialize';
public static readonly KEEPALIVE = 'keepalive';
public static readonly LOG = 'log';

View File

@ -109,6 +109,7 @@ const (
Cluster WsComponentType = "cluster"
CTLConfig WsComponentType = "config"
Document WsComponentType = "document"
History WsComponentType = "history"
Image WsComponentType = "image"
Phase WsComponentType = "phase"
Secret WsComponentType = "secret"

View File

@ -37,6 +37,7 @@ var CTLFunctionMap = map[configs.WsComponentType]func(*string, configs.WsMessage
configs.Cluster: HandleClusterRequest,
configs.CTLConfig: HandleConfigRequest,
configs.Document: HandleDocumentRequest,
configs.History: HandleHistoryRequest,
configs.Image: HandleImageRequest,
configs.Phase: HandlePhaseRequest,
configs.Secret: HandleSecretRequest,

139
pkg/ctl/history.go Executable file
View File

@ -0,0 +1,139 @@
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ctl
import (
"fmt"
"strings"
"opendev.org/airship/airshipui/pkg/configs"
"opendev.org/airship/airshipui/pkg/log"
"opendev.org/airship/airshipui/pkg/statistics"
)
// This is close to but not exactly a transaction structure from statistics, it's redone here because reasons
type record struct {
SubComponent configs.WsSubComponentType
User *string
ActionType *string
Target *string
Success bool
Started int64
Elapsed int64
Stopped int64
}
// HandleHistoryRequest will flop between requests so we don't have to have them all mapped as function calls
// This will wait for the sub component to complete before responding. The assumption is this is an async request
func HandleHistoryRequest(user *string, request configs.WsMessage) configs.WsMessage {
response := configs.WsMessage{
Type: configs.CTL,
Component: configs.History,
SubComponent: request.SubComponent,
}
var err error
subComponent := request.SubComponent
switch subComponent {
case configs.GetDefaults:
response.Data, err = getData(nil, nil)
default:
err = fmt.Errorf("Subcomponent %s not found", request.SubComponent)
}
if err != nil {
e := err.Error()
response.Error = &e
}
return response
}
// getData will return all rows within a specific date range
func getData(notBefore *int64, notAfter *int64) (map[string][]record, error) {
var wherePstmt strings.Builder
where := false
// because we may want data within a range add range slice where statements
if notBefore != nil {
where = true
wherePstmt.WriteString(fmt.Sprintf(" where started >= %d", notBefore))
}
if notAfter != nil {
if where {
wherePstmt.WriteString(fmt.Sprintf(" and stopped <= %d", notAfter))
} else {
wherePstmt.WriteString(fmt.Sprintf(" where stopped <= %d", notAfter))
}
}
data := map[string][]record{}
for _, table := range statistics.Tables {
// create a basic prepared statement to get data
// Why a prepared statement? Little Bobby Tables is why:
// https://xkcd.com/327/
pstmt := fmt.Sprintf("select * from %s", table)
if where {
pstmt += wherePstmt.String()
}
// Dark Helmet: Why are we always preparing? Just go!
stmt, err := statistics.DB.Prepare(pstmt)
if err != nil {
log.Error(err)
return nil, err
}
// Get the rows back from the query
rows, err := stmt.Query()
if err != nil {
log.Error(err)
return nil, err
}
defer rows.Close()
records := []record{}
for rows.Next() {
var r record
err = rows.Scan(
&r.SubComponent,
&r.User,
&r.ActionType,
&r.Target,
&r.Success,
&r.Started,
&r.Elapsed,
&r.Stopped,
)
if err != nil {
log.Error(err)
return nil, err
}
records = append(records, r)
}
err = rows.Err()
if err != nil {
log.Error(err)
return nil, err
}
if len(records) > 0 {
data[table] = records
}
}
return data, nil
}

View File

@ -42,8 +42,10 @@ var (
doNotRecordRegex = regexp.MustCompile(`(?i)^get|^init$`) // the default gets we don't care about
writeMutex sync.Mutex
db *sql.DB
tables = []string{"baremetal", "cluster", "config", "document", "image", "phase", "secret"}
// DB is public so other packages can do selects on it
DB *sql.DB
// Tables is public so other packages can range over it
Tables = []string{"baremetal", "cluster", "config", "document", "image", "phase", "secret"}
)
const (
@ -54,9 +56,9 @@ const (
type text check(type in ('direct', 'phase')) null,
target text null,
success tinyint(1) default 0,
started timestamp,
started bigint,
elapsed bigint,
stopped timestamp)`
stopped bigint)`
// the prepared statement used for inserts
// TODO (aschiefe): determine if we need to batch inserts
insert = `INSERT INTO table(subcomponent,
@ -81,7 +83,7 @@ func Init() {
var err error
// TODO (aschiefe): encrypt & password protect the database
// TODO (aschiefe): pull the db location out to the confing
db, err = sql.Open("sqlite3", "./sqlite/statistics.db")
DB, err = sql.Open("sqlite3", "./sqlite/statistics.db")
if err != nil {
log.Fatal(err)
}
@ -95,8 +97,8 @@ func Init() {
// createTables is only used when there is no database to write the correct structure for the records
func createTables() error {
for _, table := range tables {
stmt, err := db.Prepare(strings.ReplaceAll(tableCreate, "table", table))
for _, table := range Tables {
stmt, err := DB.Prepare(strings.ReplaceAll(tableCreate, "table", table))
if err != nil {
return err
@ -127,7 +129,7 @@ func NewTransaction(user *string, request configs.WsMessage) *Transaction {
// Complete will put an entry into the statistics database for the transaction
func (transaction *Transaction) Complete(errorMessageNotPresent bool) {
if transaction.User != nil && transaction.Recordable {
stmt, err := db.Prepare(strings.ReplaceAll(insert, "table", string(transaction.Table)))
stmt, err := DB.Prepare(strings.ReplaceAll(insert, "table", string(transaction.Table)))
if err != nil {
log.Error(err)
return