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:
+
+
+
+
+
+
+
+
+ Filter
+
+
+
+
+
+
subComponent
+
{{row.subComponent}}
+
+
+
user
+
{{row.user}}
+
+
+
type
+
{{row.type}}
+
+
+
target
+
{{row.target}}
+
+
+
success
+
{{row.success}}
+
+
+
started
+
{{row.started}}
+
+
+
elapsed
+
{{row.elapsed}}
+
+
+
stopped
+
{{row.stopped}}
+
+
+
+
+
+
No data matching the filter "{{input.value}}"
+
+
+
+
+
+
+
Loading history for {{selectedHistory}} please wait...
+
+
+
No command history data is available for {{selectedHistory}}
+
+
\ 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