diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 7a4c042..0e2bdd3 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -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', diff --git a/client/src/app/ctl/ctl-routing.module.ts b/client/src/app/ctl/ctl-routing.module.ts index e1db96d..2afd937 100644 --- a/client/src/app/ctl/ctl-routing.module.ts +++ b/client/src/app/ctl/ctl-routing.module.ts @@ -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({ diff --git a/client/src/app/ctl/ctl.module.ts b/client/src/app/ctl/ctl.module.ts index 7723625..49cd74d 100644 --- a/client/src/app/ctl/ctl.module.ts +++ b/client/src/app/ctl/ctl.module.ts @@ -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: [] diff --git a/client/src/app/ctl/history/history.component.css b/client/src/app/ctl/history/history.component.css new file mode 100755 index 0000000..793e7d9 --- /dev/null +++ b/client/src/app/ctl/history/history.component.css @@ -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%; +} \ No newline at end of file diff --git a/client/src/app/ctl/history/history.component.html b/client/src/app/ctl/history/history.component.html new file mode 100755 index 0000000..5d9a9f8 --- /dev/null +++ b/client/src/app/ctl/history/history.component.html @@ -0,0 +1,77 @@ +

Airship CTL Command History

+ +
+ + + + +
+ Command History:   + +    +
+
+ +
+

Loading history for {{selectedHistory}} please wait...

+
+ +
\ No newline at end of file diff --git a/client/src/app/ctl/history/history.component.spec.ts b/client/src/app/ctl/history/history.component.spec.ts new file mode 100755 index 0000000..21a1824 --- /dev/null +++ b/client/src/app/ctl/history/history.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/client/src/app/ctl/history/history.component.ts b/client/src/app/ctl/history/history.component.ts new file mode 100755 index 0000000..99bb326 --- /dev/null +++ b/client/src/app/ctl/history/history.component.ts @@ -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> = new Map(); + dataSource: MatTableDataSource = new MatTableDataSource(); + @ViewChild(MatSort) sort: MatSort; + @ViewChild(MatPaginator) paginator: MatPaginator; + + constructor(private websocketService: WsService) { + this.websocketService.registerFunctions(this); + } + + async receiver(message: WsMessage): Promise { + 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); + } +} diff --git a/client/src/app/ctl/history/history.models.ts b/client/src/app/ctl/history/history.models.ts new file mode 100755 index 0000000..37be071 --- /dev/null +++ b/client/src/app/ctl/history/history.models.ts @@ -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; +} diff --git a/client/src/app/ctl/history/history.module.ts b/client/src/app/ctl/history/history.module.ts new file mode 100755 index 0000000..1a83654 --- /dev/null +++ b/client/src/app/ctl/history/history.module.ts @@ -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 { } diff --git a/client/src/assets/icons/history.svg b/client/src/assets/icons/history.svg new file mode 100755 index 0000000..3dfff5d --- /dev/null +++ b/client/src/assets/icons/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/services/icon/icons.enum.ts b/client/src/services/icon/icons.enum.ts index c0d2052..ebb6d74 100644 --- a/client/src/services/icon/icons.enum.ts +++ b/client/src/services/icon/icons.enum.ts @@ -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' } diff --git a/client/src/services/ws/ws.models.ts b/client/src/services/ws/ws.models.ts index 9e191c3..2631809 100755 --- a/client/src/services/ws/ws.models.ts +++ b/client/src/services/ws/ws.models.ts @@ -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'; diff --git a/pkg/configs/configs.go b/pkg/configs/configs.go index 749ce98..20eeeac 100644 --- a/pkg/configs/configs.go +++ b/pkg/configs/configs.go @@ -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" diff --git a/pkg/ctl/airshipctl.go b/pkg/ctl/airshipctl.go index 6d780b0..25e36a1 100644 --- a/pkg/ctl/airshipctl.go +++ b/pkg/ctl/airshipctl.go @@ -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, diff --git a/pkg/ctl/history.go b/pkg/ctl/history.go new file mode 100755 index 0000000..6c47936 --- /dev/null +++ b/pkg/ctl/history.go @@ -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 +} diff --git a/pkg/statistics/recorder.go b/pkg/statistics/recorder.go index 4f39def..e37ed0a 100755 --- a/pkg/statistics/recorder.go +++ b/pkg/statistics/recorder.go @@ -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