diff --git a/cmd/new-ui/v1beta1/main.go b/cmd/new-ui/v1beta1/main.go index 1566dfca864..6c94e1605b3 100644 --- a/cmd/new-ui/v1beta1/main.go +++ b/cmd/new-ui/v1beta1/main.go @@ -59,6 +59,7 @@ func main() { http.HandleFunc("/katib/fetch_hp_job_info/", kuh.FetchHPJobInfo) http.HandleFunc("/katib/fetch_hp_job_trial_info/", kuh.FetchHPJobTrialInfo) + http.HandleFunc("/katib/fetch_hp_job_label_info/", kuh.FetchHPJobLabelInfo) http.HandleFunc("/katib/fetch_nas_job_info/", kuh.FetchNASJobInfo) http.HandleFunc("/katib/fetch_trial_templates/", kuh.FetchTrialTemplates) diff --git a/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-settings.const.ts b/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-settings.const.ts index 0ca7f3c0b90..b34b2b8c01b 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-settings.const.ts +++ b/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-settings.const.ts @@ -242,6 +242,29 @@ export const DartsSettings: AlgorithmSetting[] = [ }, ]; +export const PbtSettings: AlgorithmSetting[] = [ + { + name: 'suggestion_trial_dir', + value: '/var/log/katib/checkpoints/', + type: AlgorithmSettingType.STRING, + }, + { + name: 'n_population', + value: 40, + type: AlgorithmSettingType.INTEGER, + }, + { + name: 'resample_probability', + value: null, + type: AlgorithmSettingType.FLOAT, + }, + { + name: 'truncation_threshold', + value: 0.2, + type: AlgorithmSettingType.FLOAT, + }, +]; + export const EarlyStoppingSettings: AlgorithmSetting[] = [ { name: 'min_trials_required', @@ -271,4 +294,5 @@ export const AlgorithmSettingsMap: { [key: string]: AlgorithmSetting[] } = { [AlgorithmsEnum.SOBOL]: SOBOLSettings, [AlgorithmsEnum.ENAS]: ENASSettings, [AlgorithmsEnum.DARTS]: DartsSettings, + [AlgorithmsEnum.PBT]: PbtSettings, }; diff --git a/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-types.const.ts b/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-types.const.ts index 607e6f2db63..4f1f0aa0cfd 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-types.const.ts +++ b/pkg/new-ui/v1beta1/frontend/src/app/constants/algorithms-types.const.ts @@ -12,6 +12,7 @@ export const AlgorithmNames = { [AlgorithmsEnum.MULTIVARIATE_TPE]: 'Multivariate Tree of Parzen Estimators', [AlgorithmsEnum.CMAES]: 'Covariance Matrix Adaptation: Evolution Strategy', [AlgorithmsEnum.SOBOL]: 'Sobol Quasirandom Sequence', + [AlgorithmsEnum.PBT]: 'Population Based Training', }; export const NasAlgorithmNames = { diff --git a/pkg/new-ui/v1beta1/frontend/src/app/enumerations/algorithms.enum.ts b/pkg/new-ui/v1beta1/frontend/src/app/enumerations/algorithms.enum.ts index 425d423199d..8f7476468b1 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/enumerations/algorithms.enum.ts +++ b/pkg/new-ui/v1beta1/frontend/src/app/enumerations/algorithms.enum.ts @@ -9,6 +9,7 @@ export enum AlgorithmsEnum { SOBOL = 'sobol', ENAS = 'enas', DARTS = 'darts', + PBT = 'pbt', } export enum EarlyStoppingAlgorithmsEnum { diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.html b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.html index 4d1233302cf..e650ccee888 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.html +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.html @@ -60,6 +60,13 @@ [experimentJson]="experimentDetails" > + + + diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.ts b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.ts index 33f129b07c7..f4e4889f5d4 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.ts +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.component.ts @@ -30,6 +30,7 @@ export class ExperimentDetailsComponent implements OnInit, OnDestroy { columns: string[] = []; details: string[][] = []; experimentTrialsCsv: string; + labelCsv: string; hoveredTrial: number; experimentDetails: ExperimentK8s; showGraph: boolean; @@ -94,6 +95,11 @@ export class ExperimentDetailsComponent implements OnInit, OnDestroy { this.details = this.parseTrialsDetails(data.details); this.showGraph = true; }); + this.backendService + .getExperimentLabelInfo(this.name, this.namespace) + .subscribe(response => { + this.labelCsv = response; + }); this.backendService .getExperiment(this.name, this.namespace) .subscribe((response: ExperimentK8s) => { diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.module.ts b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.module.ts index 541edb0af97..4a8983364f8 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.module.ts +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/experiment-details.module.ts @@ -16,6 +16,7 @@ import { ExperimentOverviewModule } from './overview/experiment-overview.module' import { ExperimentDetailsTabModule } from './details/experiment-details-tab.module'; import { TrialsGraphModule } from './trials-graph/trials-graph.module'; import { ExperimentYamlModule } from './yaml/experiment-yaml.module'; +import { PbtTabModule } from './pbt/pbt-tab-loader.module'; @NgModule({ declarations: [ExperimentDetailsComponent], @@ -33,6 +34,7 @@ import { ExperimentYamlModule } from './yaml/experiment-yaml.module'; MatProgressSpinnerModule, ExperimentYamlModule, TitleActionsToolbarModule, + PbtTabModule, ], exports: [ExperimentDetailsComponent], }) diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab-loader.module.ts b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab-loader.module.ts new file mode 100644 index 00000000000..0b45f713e29 --- /dev/null +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab-loader.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; + +import { PbtTabComponent } from './pbt-tab.component'; + +@NgModule({ + declarations: [PbtTabComponent], + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatSelectModule, + MatCheckboxModule, + ], + exports: [PbtTabComponent], +}) +export class PbtTabModule {} diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.html b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.html new file mode 100644 index 00000000000..f9627740ca0 --- /dev/null +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.html @@ -0,0 +1,21 @@ +
+
+ + Y-Axis + + + {{ name }} + + + + + Display Seed Traces +
+ +
+
diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.scss b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.scss new file mode 100644 index 00000000000..47bac1bf984 --- /dev/null +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.scss @@ -0,0 +1,48 @@ +:host { + display: block; +} + +.pbt-wrapper { + position: relative; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.pbt-options-wrapper { + display: flex; + align-items: center; + flex-direction: row; +} + +.pbt-option { + margin: 10px; +} + +.d3-tab-graph { + width: 400px; + text-align: center; + + @media (min-width: 768px) { + width: 700px; + } + + @media (min-width: 1024px) { + width: 1000px; + } + + @media (min-width: 1400px) { + width: 1300px; + } + + @media (min-width: 1650px) { + width: 1600px; + } + + @media (min-width: 2000px) { + width: 1900px; + } + + height: 600px; +} diff --git a/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.ts b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.ts new file mode 100644 index 00000000000..c2f6172f37b --- /dev/null +++ b/pkg/new-ui/v1beta1/frontend/src/app/pages/experiment-details/pbt/pbt-tab.component.ts @@ -0,0 +1,463 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, + AfterViewInit, + SimpleChanges, + ElementRef, + ViewChild, +} from '@angular/core'; +import lowerCase from 'lodash-es/lowerCase'; +import { safeDivision } from 'src/app/shared/utils'; +import { ExperimentK8s } from '../../../models/experiment.k8s.model'; + +import { Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +import { StatusEnum } from 'src/app/enumerations/status.enum'; + +declare let d3: any; + +type PbtPoint = { + trialName: string; + parentUid: string; + parameters: Object; // all y-axis possible values (parameters + alternativeMetrics) + generation: number; // generation + metricValue: number; // evaluation metric +}; + +@Component({ + selector: 'app-experiment-pbt-tab', + templateUrl: './pbt-tab.component.html', + styleUrls: ['./pbt-tab.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PbtTabComponent implements OnChanges, OnInit, AfterViewInit { + @ViewChild('pbtGraph') graphWrapper: ElementRef; // used to manipulate svg dom + + graph: any; // svg dom + selectableNames: string[]; // list of parameters/metrics for UI dropdown + selectedName: string; // user selected parameter/metric + displayTrace: boolean; + + private labelData: { [trialName: string]: PbtPoint } = {}; + private trialData: { [trialName: string]: Object } = {}; + private parameterNames: string[]; // parameter names + private goalName: string = ''; + private data: PbtPoint[][] = []; // data sorted by generation and segment + private graphHelper: any; // graph metadata and auxiliary info + + @Input() + experiment: ExperimentK8s; + + @Input() + labelCsv: string[] = []; + + @Input() + experimentTrialsCsv: string[] = []; + + constructor(@Inject(DOCUMENT) private document: Document) { + this.graphHelper = {}; + this.graphHelper.margin = { top: 10, right: 30, bottom: 30, left: 60 }; + this.graphHelper.width = + 460 - this.graphHelper.margin.left - this.graphHelper.margin.right; + this.graphHelper.height = + 400 - this.graphHelper.margin.top - this.graphHelper.margin.bottom; + // Track angular initialization since using @Input from template + this.graphHelper.ngInit = false; + } + + ngOnInit(): void { + if (this.experiment.spec.algorithm.algorithmName != 'pbt') { + // Prevent initialization if not Pbt + return; + } + + // Create full list of parameters + this.parameterNames = this.experiment.spec.parameters.map(param => { + return param.name; + }); + // Identify goal + this.goalName = this.experiment.spec.objective.objectiveMetricName; + // Create full list of selectable names + this.selectableNames = [...this.parameterNames]; + if ( + this.experiment.spec.objective.additionalMetricNames && + this.experiment.spec.objective.additionalMetricNames.length > 0 + ) { + this.selectableNames = [ + ...this.selectableNames, + ...this.experiment.spec.objective.additionalMetricNames, + ]; + } + // Create converters for all possible y-axes + this.graphHelper.yMeta = {}; + for (const param of this.experiment.spec.parameters) { + this.graphHelper.yMeta[param.name] = {}; + if ( + param.parameterType == 'discrete' || + param.parameterType == 'categorical' + ) { + this.graphHelper.yMeta[param.name].transform = x => { + return x; + }; + this.graphHelper.yMeta[param.name].isNumber = false; + } else if (param.parameterType == 'double') { + this.graphHelper.yMeta[param.name].transform = x => { + return parseFloat(x); + }; + this.graphHelper.yMeta[param.name].isNumber = true; + } else { + this.graphHelper.yMeta[param.name].transform = x => { + return parseInt(x); + }; + this.graphHelper.yMeta[param.name].isNumber = true; + } + } + if (this.experiment.spec.objective.additionalMetricNames) { + for (const metricName of this.experiment.spec.objective + .additionalMetricNames) { + if (this.graphHelper.yMeta.hasOwnProperty(metricName)) { + console.warn( + 'Additional metric name conflict with parameter name; ignoring metric:', + metricName, + ); + continue; + } + this.graphHelper.yMeta[metricName] = {}; + this.graphHelper.yMeta[metricName].transform = x => { + return parseFloat(x); + }; + this.graphHelper.yMeta[metricName].isNumber = true; + } + } + + this.graphHelper.ngInit = true; + } + + ngAfterViewInit(): void { + if (this.experiment.spec.algorithm.algorithmName != 'pbt') { + // Remove pbt tab and tab content + const tabs = document.querySelectorAll('.mat-tab-labels .mat-tab-label'); + for (let i = 0; i < tabs.length; i++) { + if ( + tabs[i] + .querySelector('.mat-tab-label-content') + .innerHTML.includes('PBT') + ) { + const tabId = tabs[i].getAttribute('id'); + const tabBodyId = tabId.replace('label', 'content'); + const tabBody = document.querySelector('#' + tabBodyId); + tabBody.remove(); + tabs[i].remove(); + break; + } + } + return; + } + // Specify default choice for dropdown menu + this.selectedName = this.selectableNames[0]; + // Specify default trace view + this.displayTrace = false; + } + + onDropdownChange() { + // Trigger graph redraw on dropdown change event + this.clearGraph(); + this.updateGraph(); + } + + onTraceChange() { + // Trigger graph redraw on trace change event + // TODO: could use d3.select(..).remove() instead of recreating + this.clearGraph(); + this.updateGraph(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.graphHelper || !this.graphHelper.ngInit) { + console.warn( + 'graphHelper not initialized yet, attempting manual call to ngOnInit()', + ); + this.ngOnInit(); + } + // Recompute formatted plotting points on data input changes + let updatePoints = false; + if (changes.experimentTrialsCsv && this.experimentTrialsCsv) { + let trialArr = d3.csv.parse(this.experimentTrialsCsv); + for (let trial of trialArr) { + if ( + trial['Status'] == StatusEnum.SUCCEEDED && + !this.trialData.hasOwnProperty(trial['trialName']) + ) { + this.trialData[trial['trialName']] = trial; + updatePoints = true; + } + } + } + + if (changes.labelCsv && this.labelCsv) { + let labelArr = d3.csv.parse(this.labelCsv); + for (let label of labelArr) { + if (this.labelData.hasOwnProperty(label['trialName'])) { + continue; + } + let newEntry: PbtPoint = { + trialName: label['trialName'], + parentUid: label['pbt.suggestion.katib.kubeflow.org/parent'], + generation: parseInt( + label['pbt.suggestion.katib.kubeflow.org/generation'], + ), + parameters: undefined, + metricValue: undefined, + }; + this.labelData[newEntry.trialName] = newEntry; + updatePoints = true; + } + } + + if (updatePoints) { + // Lazy; reprocess all points + let points: PbtPoint[] = []; + Object.values(this.labelData).forEach(entry => { + let point = {} as PbtPoint; + point.trialName = entry.trialName; + point.generation = entry.generation; + point.parentUid = entry.parentUid; + + // Find corresponding trial data + let trial = this.trialData[point.trialName]; + if (trial !== undefined) { + point.metricValue = parseFloat(trial[this.goalName]); + point.parameters = {}; + for (let p of this.selectableNames) { + point.parameters[p] = this.graphHelper.yMeta[p].transform(trial[p]); + } + } else { + point.metricValue = undefined; + } + points.push(point); + }); + + // Generate segments + // Group seeds + let remaining_points = {}; + for (let p of points) { + remaining_points[p.trialName] = p; + } + + this.data = []; + while (Object.keys(remaining_points).length > 0) { + let seeds = this.maxGenerationTrials(remaining_points); + for (let seed of seeds) { + let segment = []; + let v = seed; + while (v) { + segment.push(v); + let delete_entry = v.trialName; + v = remaining_points[v.parentUid]; + delete remaining_points[delete_entry]; + } + this.data.push(segment); + } + } + + this.clearGraph(); + this.updateGraph(); + } + } + + private maxGenerationTrials(d) { + let end_seeds = []; + let max_generation = 0; + for (let k of Object.keys(d)) { + if (d[k].generation > max_generation) { + max_generation = d[k].generation; + end_seeds = []; + end_seeds.push(d[k]); + } else if (d[k].generation === max_generation) { + end_seeds.push(d[k]); + } + } + return end_seeds; + } + + private clearGraph() { + // Clear any existing views from graphs object + if (this.graph) { + // this.graph.remove(); // d3 remove from DOM + d3.select(this.graphWrapper.nativeElement).select('svg').remove(); + } + this.graph = undefined; + } + + private createGraph() { + this.graph = d3 + .select(this.graphWrapper.nativeElement) + .append('svg') + .attr( + 'width', + this.graphHelper.width + + this.graphHelper.margin.left + + this.graphHelper.margin.right, + ) + .attr( + 'height', + this.graphHelper.height + + this.graphHelper.margin.top + + this.graphHelper.margin.bottom, + ) + .append('g') + .attr( + 'transform', + 'translate(' + + this.graphHelper.margin.left + + ',' + + this.graphHelper.margin.top + + ')', + ); + } + + private getRangeX() { + const xValues = Object.values(this.labelData).map(entry => { + return entry.generation; + }); + return d3.scale + .linear() + .domain(d3.extent(xValues)) + .range([0, this.graphHelper.width]); + } + + private getRangeY(key) { + if (this.selectableNames.includes(key)) { + const paramValues = Object.keys(this.trialData).map(trialName => { + return this.graphHelper.yMeta[key].transform( + this.trialData[trialName][key], + ); + }); + + if (this.graphHelper.yMeta[key].isNumber) { + return d3.scale + .linear() + .domain(d3.extent(paramValues)) + .range([this.graphHelper.height, 0]); + } else { + paramValues.sort((a, b) => a - b); + return d3.scale + .ordinal() + .domain(paramValues) + .range([this.graphHelper.height, 0]); + } + } else { + console.error('Key(' + key + ') not found in y-axis list'); + } + } + + private getColorScaleZ() { + let values = []; + for (const segment of this.data) { + for (const point of segment) { + values.push(point.metricValue); + } + } + return d3.scale + .linear() + .domain(d3.extent(values)) + .interpolate(d3.interpolateHcl) + .range([d3.rgb('#cfd8dc'), d3.rgb('#263238')]); + } + + private updateGraph() { + if (!this.graphHelper || !this.graphHelper.ngInit) { + // ngOnInit not called yet + return; + } + if (!this.data.length || this.data.length == 0) { + // Data not initialized + return; + } + if (!this.graph) { + this.createGraph(); + } + + // Add X axis + let xAxis = this.getRangeX(); + this.graph + .append('g') + .attr('transform', 'translate(0,' + this.graphHelper.height + ')') + .call(d3.svg.axis().scale(xAxis).orient('bottom')); + this.graph + .append('text') + .attr('text-anchor', 'middle') + .attr('x', this.graphHelper.width / 2) + .attr('y', this.graphHelper.height + 30) + .text('Generation'); + // Add Y axis + let yAxis = this.getRangeY(this.selectedName); + this.graph.append('g').call(d3.svg.axis().scale(yAxis).orient('left')); + this.graph + .append('text') + .attr('text-anchor', 'middle') + .attr('x', -this.graphHelper.height / 2) + .attr('y', -50) + .attr('transform', 'rotate(-90)') + .text(this.selectedName); + // Change line width + this.graph + .selectAll('path') + .style({ stroke: 'black', fill: 'none', 'stroke-width': '1px' }); + + // Add the points + const sparam = this.selectedName; + const colorScale = this.getColorScaleZ(); + for (const segment of this.data) { + // Plot only valid points + const validSegment = segment.filter( + point => + point.parameters && + point.parameters[sparam] && + point.metricValue !== undefined, + ); + this.graph + .append('g') + .selectAll('dot') + .data(validSegment) + .enter() + .append('circle') + .attr('cx', function (d) { + return xAxis(d.generation); + }) + .attr('cy', function (d) { + return yAxis(d.parameters[sparam]); + }) + .attr('r', 2) + .attr('fill', function (d) { + return colorScale(d.metricValue); + }); + + if (this.displayTrace && validSegment.length > 0) { + // Add the lines + const strokeColor = colorScale(validSegment[0].metricValue); + this.graph + .append('path') + .datum(validSegment) + .attr('fill', 'none') + .attr('stroke', strokeColor) + .attr('stroke-width', 1) + .attr( + 'd', + d3.svg + .line() + .x(function (d) { + return xAxis(d.generation); + }) + .y(function (d) { + return yAxis(d.parameters[sparam]); + }), + ); + } + } + } +} diff --git a/pkg/new-ui/v1beta1/frontend/src/app/services/backend.service.ts b/pkg/new-ui/v1beta1/frontend/src/app/services/backend.service.ts index 60ac1ab0c34..458d26b17e5 100644 --- a/pkg/new-ui/v1beta1/frontend/src/app/services/backend.service.ts +++ b/pkg/new-ui/v1beta1/frontend/src/app/services/backend.service.ts @@ -56,6 +56,12 @@ export class KWABackendService extends BackendService { return this.http.get(url).pipe(catchError(error => this.parseError(error))); } + getExperimentLabelInfo(name: string, namespace: string): Observable { + const url = `/katib/fetch_hp_job_label_info/?experimentName=${name}&namespace=${namespace}`; + + return this.http.get(url).pipe(catchError(error => this.parseError(error))); + } + getExperiment(name: string, namespace: string): Observable { const url = `/katib/fetch_experiment/?experimentName=${name}&namespace=${namespace}`; diff --git a/pkg/new-ui/v1beta1/hp.go b/pkg/new-ui/v1beta1/hp.go index 006d799c78d..9f3fb7a8502 100644 --- a/pkg/new-ui/v1beta1/hp.go +++ b/pkg/new-ui/v1beta1/hp.go @@ -245,3 +245,63 @@ func (k *KatibUIHandler) FetchHPJobTrialInfo(w http.ResponseWriter, r *http.Requ return } } + +func (k *KatibUIHandler) FetchHPJobLabelInfo(w http.ResponseWriter, r *http.Request) { + //enableCors(&w) + experimentName := r.URL.Query()["experimentName"][0] + namespace := r.URL.Query()["namespace"][0] + + trialList, err := k.katibClient.GetTrialList(experimentName, namespace) + if err != nil { + log.Printf("GetTrialList from HP job failed: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("Got Trial List") + + labelList := map[string]int{} + labelList["trialName"] = 0 + var trialRes [][]string + for _, t := range trialList.Items { + trialResText := make([]string, len(labelList)) + trialResText[labelList["trialName"]] = t.Name + for k, v := range t.ObjectMeta.Labels { + i, exists := labelList[k] + if !exists { + i = len(labelList) + labelList[k] = i + trialResText = append(trialResText, "") + } + trialResText[i] = v + } + trialRes = append(trialRes, trialResText) + } + + // Format header output + headerArr := make([]string, len(labelList)) + for k, v := range labelList { + headerArr[v] = k + } + resultText := strings.Join(headerArr, ",") + + // Format entry output + for _, row := range trialRes { + resultText += "\n" + strings.Join(row, ",") + for j := 0; j < len(labelList)-len(row); j++ { + resultText += "," + } + } + + log.Printf("Logs parsed, results:\n %v", resultText) + response, err := json.Marshal(resultText) + if err != nil { + log.Printf("Marshal result text for HP job failed: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if _, err = w.Write(response); err != nil { + log.Printf("Write result text for HP job failed: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +}