github.com/aitjcize/Overlord@v0.0.0-20240314041920-104a804cf5e8/overlord/app/common/js/FixtureWidget.jsx (about) 1 // Copyright 2015 The Chromium OS Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 // 5 // External dependencies: 6 // - JsRender: https://github.com/BorisMoore/jsrender 7 // 8 // Internal dependencies: 9 // - UploadProgressWidget 10 // 11 // View for FixtureWidget: 12 // - FixtureWidget 13 // props: 14 // app: react reference to the app object with addTerminal method 15 // client: a overlord client client object with properties object 16 // progressBars: an react reference to UploadProgressWidget instance 17 // - Display 18 // - Lights 19 // - Terminals 20 // - Controls 21 // - MainLog 22 // - AuxLogs 23 // - AuxLog 24 25 var LOG_BUF_SIZE = 8192; 26 27 var FIXTURE_WINDOW_WIDTH = 420; 28 var FIXTURE_WINDOW_MARGIN = 10; 29 30 var LIGHT_CSS_MAP = { 31 'light-toggle-off': 'label-danger', 32 'light-toggle-on': 'label-success' 33 }; 34 35 // FixtureWidget defines the layout and behavior of a fixture window, 36 // which has display, lights, terminals, controls and logs. 37 // 38 // Usage: 39 // A terminal description object that would be passed to app.addTerminal would 40 // looks like the following in json: 41 // { 42 // "name": "NUC", 43 // "mid": "ghost 1", 44 // // @path attribute is optional, without @path, it means that we are 45 // // connecting to the fixture itself. 46 // "path": "some path" 47 // } 48 // 49 // A client object would looks like the following in json: 50 // { 51 // "mid": "machine ID", 52 // "sid": "serial ID", 53 // // see properties.sample.json 54 // "properties": { 55 // "ip": "127.0.0.1", 56 // "ui": { 57 // // The main command which updates ui states. 58 // // "update_ui_status" is a script we wrote that will respect 59 // // @init_cmd and @poll attributes in lights and display.data, you can 60 // // implement your own script instead. 61 // "update_ui_command": "update_ui_status", 62 // // Lights are used to show current status of the fixture, lights has 63 // // two states: on and off, which is represent by setting "light" 64 // // attribute to 'light-toggle-on' or 'light-toggle-off' (see below) 65 // "lights": [ 66 // { 67 // // Identifier of this light, if the output of @command contains 68 // // LIGHT[@id]='light-toggle-on', then @light will be set to on. 69 // "id": "ccd", 70 // // Text to be shown 71 // "label": "CCD", 72 // // Set default state to off 73 // "light": "light-toggle-off", 74 // // Command to execute when clicked 75 // "command": "case_close_debug", 76 // // Will be called when the FixtureWidget is opened. 77 // "init_cmd": "case_close_debug status" 78 // }, 79 // { 80 // "id": "dut-lid", 81 // "label": "DUT LID", 82 // "light": "light-toggle-off", 83 // // @cmd will be execute every @interval milliseconds, you can 84 // // output LIGHT[@id]='light-toggle-on' to change the light. 85 // "poll": { 86 // "cmd": "check_dut_exists -t lid", 87 // "interval": 20000 88 // } 89 // }, ... 90 // ], 91 // // A list of terminals connected to this fixture, for example, there 92 // // might be a terminal for fixture itself and a terminal for DUT. 93 // "terminals": [ 94 // // Without @path_cmd attribute, will connect to fixture itself. 95 // { 96 // "name": "NUC" 97 // }, 98 // // @path_cmd will be used to get the path of device. 99 // { 100 // "name": "AP", 101 // "path_cmd": "ls /dev/google/Ryu_debug-*/serial/AP 2>/dev/null", 102 // } 103 // ], 104 // // A display section 105 // "display": { 106 // // A jsrender template 107 // "template": "<b>Report</b><ul><li>Version: {{:version}}</li>" 108 // "<li>Status: {{:status}}</li></ul>", 109 // "data": [ 110 // { 111 // // id: the name of data binding in the template 112 // "id": "version", 113 // // Will be called when the FixtureWidget is opened. 114 // "init_cmd": "get_version" 115 // }, 116 // { 117 // "id": "status", 118 // // @cmd will be execute every @interval milliseconds, you can 119 // // output DATA[@id]='value' to change the binding value. 120 // "poll": { 121 // "cmd": "get_status", 122 // "interval": 20000 123 // } 124 // }, ... 125 // ] 126 // }, 127 // // A list of buttons to control some functionality of the fixture. 128 // "controls": [ 129 // // A command 130 // { 131 // "name": "Factory Restart", 132 // "command": "factory_restart" 133 // }, 134 // // A command that will be toggled between two state. 135 // { 136 // "name": "Voltage Measurement", 137 // "type": "toggle", 138 // "on_command": "command to start measuring voltage", 139 // "off_command": "command to stop measuring" 140 // }, 141 // // A button that allow uploading a file, then execute a command 142 // { 143 // "name": "Update Toolkit", 144 // "type": "upload", 145 // "dest": "/tmp/install_factory_toolkit.run", 146 // // @command is optional, you can omit this if you don't need to 147 // // execute any command. 148 // "command": "rm -rf /usr/local/factory && " 149 // "sh /tmp/install_factory_toolkit.run -- -y &&" 150 // "factory_restart" 151 // }, 152 // // A button that allow execute a command, then download a file 153 // { 154 // "name": "Download Log", 155 // "type": "download", 156 // // @command is optional, you can omit this if you don't need to 157 // // execute any command. 158 // "command": "dmesg > /tmp/dmesg.log", 159 // 160 // // The filename can be specified in both static @filename 161 // // attribute, or a @filename_cmd attribute, which the output 162 // // of the command is the download filename. 163 // "filename": "/tmp/dmesg.log", 164 // // or (exclusively) 165 // "filename_cmd": "get_filename_cmd" 166 // }, 167 // // A button that opens a link 168 // { 169 // "name": "VNC", 170 // "type": "link", 171 // // @url is a URL template, here is a list of supported attributes: 172 // // host: the hostname of the webserver serving this page 173 // // port: the HTTP port of the webserver serving this page 174 // // client: the client object 175 // "url": "/third_party/noVNC/vnc_auto.html?host={{:host}}&" 176 // "port={{:port}}&path=api/agent/forward/{{:client.mid}}" 177 // "%3fport=5901" 178 // }, 179 // // A group of commands 180 // { 181 // "name": "Fixture control" 182 // "group": [ 183 // { 184 // "name": "whale close", 185 // "command": "whale close" 186 // }, 187 // { 188 // "name": "whale open", 189 // "command": "whale open" 190 // }, 191 // { 192 // "name": "io insertion", 193 // "command": "whale insert" 194 // }, 195 // { 196 // "name": "charging", 197 // "command": "whale charge" 198 // } 199 // ] 200 // } 201 // ], 202 // // Path to the log files, FixtureWidget will keep polling the latest 203 // // content of these file. 204 // "logs": [ 205 // "/var/log/factory.log", ... 206 // ] 207 // }, 208 // // What catagories this fixture belongs to. If it contains "ui", an "UI" 209 // // button will be shown on the /dashboard page. If it contains "whale", 210 // // it will be shown on the /whale page. 211 // "context": [ 212 // "ui", "whale", ... 213 // ] 214 // } 215 // } 216 var FixtureWidget = React.createClass({ 217 executeRemoteCmd: function (mid, cmd) { 218 if (!this.isMounted()) { 219 return; 220 } 221 var url = "ws" + ((window.location.protocol == "https:")? "s": "" ) + 222 "://" + window.location.host + "/api/agent/shell/" + mid + 223 "?command=" + encodeURIComponent(cmd); 224 var sock = new WebSocket(url); 225 var deferred = $.Deferred(); 226 227 sock.onopen = function (event) { 228 sock.onmessage = function (msg) { 229 if (!this.isMounted()) { 230 sock.close(); 231 return; 232 } 233 if (msg.data instanceof Blob) { 234 ReadBlobAsText(msg.data, function(text) { 235 this.refs.mainlog.appendLog(text); 236 }.bind(this)); 237 } 238 }.bind(this) 239 }.bind(this) 240 241 sock.onclose = function (event) { 242 deferred.resolve(); 243 } 244 this.socks.push(sock); 245 246 return deferred.promise(); 247 }, 248 extractUIMessages: function (text) { 249 if (typeof(this.refs.lights) != "undefined") { 250 text = this.refs.lights.extractLightMessages(text); 251 } 252 if (typeof(this.refs.display) != "undefined") { 253 text = this.refs.display.extractDataMessages(text); 254 } 255 return text; 256 }, 257 componentDidMount: function () { 258 var client = this.props.client; 259 var update_ui_command = 260 client.properties.ui.update_ui_command || 'update_ui_status'; 261 setTimeout(function() { 262 this.executeRemoteCmd(client.mid, update_ui_command); 263 }.bind(this), 1000); 264 }, 265 componentWillUnmount: function () { 266 for (var i = 0; i < this.socks.length; ++i) { 267 this.socks[i].close(); 268 } 269 }, 270 getInitialState: function () { 271 this.socks = []; 272 return {}; 273 }, 274 render: function () { 275 var client = this.props.client; 276 var ui = client.properties.ui; 277 var style = { 278 width: FIXTURE_WINDOW_WIDTH + 'px', 279 margin: FIXTURE_WINDOW_MARGIN + 'px', 280 }; 281 var display = ui.display && ( 282 <Display ref="display" client={client} fixture={this} /> 283 ) || ""; 284 var lights = ui.lights && ui.lights.length > 0 && ( 285 <Lights ref="lights" client={client} fixture={this} /> 286 ) || ""; 287 var terminals = ui.terminals && ui.terminals.length > 0 && ( 288 <Terminals client={client} app={this.props.app} fixture={this} /> 289 ) || ""; 290 var controls = ui.controls && ui.controls.length > 0 && ( 291 <Controls ref="controls" client={client} fixture={this} 292 progressBars={this.props.progressBars} /> 293 ) || ""; 294 var auxlogs = ui.logs && ui.logs.length > 0 && ( 295 <AuxLogs client={client} fixture={this} /> 296 ) || ""; 297 return ( 298 <div className="fixture-block panel panel-success" style={style}> 299 <div className="panel-heading text-center">{abbr(client.mid, 60)}</div> 300 <div className="panel-body"> 301 {display} 302 {lights} 303 {terminals} 304 {controls} 305 <MainLog ref="mainlog" fixture={this} id={client.mid} /> 306 {auxlogs} 307 </div> 308 </div> 309 ); 310 } 311 }); 312 313 var Display = React.createClass({ 314 updateDisplay: function (key, value) { 315 this.setState(function (state, props) { 316 state[key] = value; 317 }); 318 }, 319 extractDataMessages: function (msg) { 320 var patt = /DATA\[(.*?)\]\s*=\s*'(.*?)'\n?/g; 321 var found; 322 while (found = patt.exec(msg)) { 323 this.updateDisplay(found[1], found[2]); 324 } 325 return msg.replace(patt, ""); 326 }, 327 getInitialState: function () { 328 var display = this.props.client.properties.ui.display; 329 var data = {}; 330 for (var i = 0; i < display.data.length; i++) { 331 data[display.data[i].id] = "" 332 } 333 return data; 334 }, 335 componentWillMount: function() { 336 var display = this.props.client.properties.ui.display; 337 this.template = $.templates(display.template); 338 }, 339 render: function () { 340 var client = this.props.client; 341 var displayHTML = this.template.render(this.state); 342 return ( 343 <div className="status-block well well-sm"> 344 <div dangerouslySetInnerHTML={{__html: displayHTML}} /> 345 </div> 346 ); 347 } 348 }); 349 350 var Lights = React.createClass({ 351 updateLightStatus: function (id, status_class) { 352 var node = $(this.refs[id]); 353 node.removeClass(this.refs[id].props.prevLight); 354 node.addClass(status_class); 355 this.refs[id].props.prevLight = status_class; 356 }, 357 extractLightMessages: function (msg) { 358 var patt = /LIGHT\[(.*?)\]\s*=\s*'(.*?)'\n?/g; 359 var found; 360 while (found = patt.exec(msg)) { 361 this.updateLightStatus(found[1], LIGHT_CSS_MAP[found[2]]); 362 } 363 return msg.replace(patt, ""); 364 }, 365 render: function () { 366 var client = this.props.client; 367 var lights = client.properties.ui.lights || []; 368 return ( 369 <div className="status-block well well-sm"> 370 { 371 lights.map(function (light) { 372 var extra_css = ""; 373 var extra = {}; 374 if (typeof(light.command) != "undefined") { 375 extra_css = "status-light-clickable"; 376 extra.onClick = function() { 377 this.props.fixture.executeRemoteCmd(client.mid, light.command); 378 }.bind(this); 379 } 380 var light_css = LIGHT_CSS_MAP[light.light]; 381 return ( 382 <span key={light.id} className={"label " + extra_css + " " + 383 light_css} prevLight={light_css} ref={light.id} {...extra}> 384 {light.label} 385 </span> 386 ); 387 }.bind(this)) 388 } 389 </div> 390 ); 391 } 392 }); 393 394 var Terminals = React.createClass({ 395 onTerminalClick: function (event) { 396 var target = $(event.target); 397 var mid = target.data("mid"); 398 var term = target.data("term"); 399 var id = mid + "::" + term.name; 400 401 // Add mid reference to term object 402 term.mid = mid; 403 404 if (typeof(term.path_cmd) != "undefined" && 405 term.path_cmd.match(/^\s+$/) == null) { 406 getRemoteCmdOutput(mid, term.path_cmd).done(function (path) { 407 if (path.replace(/^\s+|\s+$/g, "") == "") { 408 alert("This TTY device does not exist!"); 409 } else { 410 term.path = path; 411 this.props.app.addTerminal(id, term); 412 } 413 }.bind(this)); 414 return; 415 } 416 417 this.props.app.addTerminal(id, term); 418 }, 419 render: function () { 420 var client = this.props.client; 421 var terminals = client.properties.ui.terminals || []; 422 return ( 423 <div className="status-block well well-sm"> 424 { 425 terminals.map(function (term) { 426 return ( 427 <button className="btn btn-xs btn-info" data-mid={client.mid} 428 data-term={JSON.stringify(term)} key={term.name} 429 onClick={this.onTerminalClick}> 430 {term.name} 431 </button> 432 ); 433 }.bind(this)) 434 } 435 </div> 436 ); 437 } 438 }); 439 440 var Controls = React.createClass({ 441 onCommandClicked: function (event) { 442 var target = $(event.target); 443 var ctrl = target.data("ctrl"); 444 var mid = target.data("mid"); 445 var fixture = this.props.fixture; 446 447 if (ctrl.type == "toggle") { 448 if (target.hasClass("active")) { 449 fixture.executeRemoteCmd(mid, ctrl.off_command); 450 target.removeClass("active"); 451 } else { 452 fixture.executeRemoteCmd(mid, ctrl.on_command); 453 target.addClass("active"); 454 } 455 } else if (ctrl.type == "download") { 456 // Helper function for downloading a file 457 var downloadFile = function (filename) { 458 var url = window.location.protocol + "//" + window.location.host + 459 "/api/agent/download/" + mid + 460 "?filename=" + filename; 461 $("<iframe src='" + url + "' style='display:none'>" + 462 "</iframe>").appendTo('body'); 463 target.text(ctrl.name); 464 target.removeClass("active"); 465 } 466 var startDownload = function (file_path) { 467 // Check if there is filename_cmd 468 if (typeof(ctrl.filename_cmd) != "undefined") { 469 getRemoteCmdOutput(mid, ctrl.filename_cmd) 470 .done(function (path) { downloadFile(path); }); 471 } else { 472 downloadFile(file_path); 473 } 474 } 475 var file_path = ctrl.filename; 476 if (typeof(file_path) == "undefined" && 477 typeof(ctrl.filename_cmd) == "undefined") { 478 file_path = prompt('Enter abosolute path to file:'); 479 } 480 if ((file_path == null || file_path == "") && 481 typeof(ctrl.filename_cmd) == "undefined") { 482 return; 483 } 484 485 target.text(ctrl.name + " (Processing...)"); 486 target.addClass("active"); 487 if (typeof(ctrl.command) != "undefined") { 488 fixture.executeRemoteCmd(mid, ctrl.command) 489 .done(function() { startDownload(file_path); }); 490 } else { 491 startDownload(file_path); 492 } 493 } else { 494 fixture.executeRemoteCmd(mid, ctrl.command); 495 } 496 }, 497 onUploadButtonChanged: function (event) { 498 var file = event.target; 499 var mid = $(file).data("mid"); 500 var ctrl = $(file).data("ctrl"); 501 502 var runCommand = function () { 503 if (typeof(ctrl.command) != "undefined") { 504 this.props.fixture.executeRemoteCmd(mid, ctrl.command); 505 } 506 // Reset the file value, so user can click the button again. 507 file.value = ""; 508 }; 509 510 if (file.value != "") { 511 this.props.progressBars.upload("/api/agent/upload/" + mid, 512 file.files[0], ctrl.dest, 513 undefined, runCommand.bind(this)); 514 } 515 }, 516 componentDidMount: function () { 517 $('input[type=file]').fileinput(); 518 }, 519 render: function () { 520 var client = this.props.client; 521 var mid = client.mid; 522 var controls = client.properties.ui.controls || []; 523 var btnClasses = "btn btn-xs btn-primary"; 524 return ( 525 <div className="controls-block well well-sm"> 526 { 527 controls.map(function (control) { 528 if (typeof(control.group) != "undefined") { // sub-group 529 return ( 530 <div className="well well-sm well-inner" key={control.name}> 531 {control.name}<br /> 532 { 533 control.group.map(function (ctrl) { 534 return ( 535 <button key={ctrl.name} 536 className="command-btn btn btn-xs btn-warning" 537 data-mid={mid} data-ctrl={JSON.stringify(ctrl)} 538 onClick={this.onCommandClicked}> 539 {ctrl.name} 540 </button> 541 ); 542 }.bind(this)) 543 } 544 </div> 545 ); 546 } 547 if (control.type == "upload") { 548 return ( 549 <input type="file" className="file" 550 data-browse-label={control.name} 551 data-browse-icon="" data-show-preview="false" 552 data-show-caption="false" data-show-upload="false" 553 data-show-remove="false" data-browse-class={btnClasses} 554 data-mid={mid} data-ctrl={JSON.stringify(control)} 555 onChange={this.onUploadButtonChanged} /> 556 ); 557 } else if (control.type == "link") { 558 var data = { 559 'host': location.hostname, 560 'port': location.port, 561 'client': client 562 }; 563 var url = $.templates(control.url).render(data); 564 return ( 565 <a key={control.name} className={"command-btn " + btnClasses} 566 href={url} target="_blank"> 567 {control.name} 568 </a> 569 ); 570 } else { 571 return ( 572 <div key={control.name} 573 className={"command-btn " + btnClasses} 574 data-mid={mid} data-ctrl={JSON.stringify(control)} 575 onClick={this.onCommandClicked}> 576 {control.name} 577 </div> 578 ); 579 } 580 }.bind(this)) 581 } 582 </div> 583 ); 584 } 585 }); 586 587 var MainLog = React.createClass({ 588 appendLog: function (text) { 589 var odiv = this.odiv; 590 var innerText = $(odiv).text(); 591 592 text = this.props.fixture.extractUIMessages(text); 593 innerText += text; 594 if (innerText.length > LOG_BUF_SIZE) { 595 innerText = innerText.substr(innerText.length - 596 LOG_BUF_SIZE, LOG_BUF_SIZE); 597 } 598 $(odiv).text(innerText); 599 odiv.scrollTop = odiv.scrollHeight; 600 }, 601 componentDidMount: function () { 602 this.odiv = this.refs["log-" + this.props.id]; 603 }, 604 render: function () { 605 return ( 606 <div className="log log-main well well-sm" ref={"log-" + this.props.id}> 607 </div> 608 ); 609 } 610 }); 611 612 var AuxLogs = React.createClass({ 613 render: function () { 614 var client = this.props.client; 615 var logs = client.properties.ui.logs || []; 616 return ( 617 <div className="log-block"> 618 { 619 logs.map(function (filename) { 620 return ( 621 <AuxLog key={filename} mid={client.mid} filename={filename} 622 fixture={this.props.fixture}/> 623 ) 624 }.bind(this)) 625 } 626 </div> 627 ); 628 } 629 }); 630 631 var AuxLog = React.createClass({ 632 componentDidMount: function () { 633 var url = "ws" + ((window.location.protocol == "https:")? "s": "" ) + 634 "://" + window.location.host + "/api/agent/shell/" + 635 this.props.mid + "?command=" + 636 encodeURIComponent("tail -f " + this.props.filename); 637 var sock = new WebSocket(url); 638 639 sock.onopen = function () { 640 var odiv = this.refs["log-" + this.props.mid]; 641 sock.onmessage = function (msg) { 642 if (msg.data instanceof Blob) { 643 ReadBlobAsText(msg.data, function (text) { 644 var innerText = $(odiv).text(); 645 text = this.props.fixture.extractUIMessages(text); 646 innerText += text; 647 if (innerText.length > LOG_BUF_SIZE) { 648 innerText = innerText.substr(innerText.length - 649 LOG_BUF_SIZE, LOG_BUF_SIZE); 650 } 651 $(odiv).text(innerText); 652 odiv.scrollTop = odiv.scrollHeight; 653 }.bind(this)); 654 } 655 }.bind(this) 656 }.bind(this) 657 this.sock = sock; 658 }, 659 componentWillUnmount: function() { 660 this.sock.close(); 661 }, 662 render: function () { 663 return ( 664 <div> 665 {this.props.filename + ":"} 666 <div className="log log-aux well well-sm" ref={"log-" + this.props.mid}> 667 </div> 668 </div> 669 ); 670 } 671 });