code.gitea.io/gitea@v1.21.7/web_src/js/components/RepoActionView.vue (about) 1 <script> 2 import {SvgIcon} from '../svg.js'; 3 import ActionRunStatus from './ActionRunStatus.vue'; 4 import {createApp} from 'vue'; 5 import {toggleElem} from '../utils/dom.js'; 6 import {getCurrentLocale} from '../utils.js'; 7 import {renderAnsi} from '../render/ansi.js'; 8 import {POST} from '../modules/fetch.js'; 9 10 const sfc = { 11 name: 'RepoActionView', 12 components: { 13 SvgIcon, 14 ActionRunStatus, 15 }, 16 props: { 17 runIndex: String, 18 jobIndex: String, 19 actionsURL: String, 20 locale: Object, 21 }, 22 23 data() { 24 return { 25 // internal state 26 loading: false, 27 intervalID: null, 28 currentJobStepsStates: [], 29 artifacts: [], 30 onHoverRerunIndex: -1, 31 menuVisible: false, 32 isFullScreen: false, 33 timeVisible: { 34 'log-time-stamp': false, 35 'log-time-seconds': false, 36 }, 37 38 // provided by backend 39 run: { 40 link: '', 41 title: '', 42 status: '', 43 canCancel: false, 44 canApprove: false, 45 canRerun: false, 46 done: false, 47 jobs: [ 48 // { 49 // id: 0, 50 // name: '', 51 // status: '', 52 // canRerun: false, 53 // duration: '', 54 // }, 55 ], 56 commit: { 57 localeCommit: '', 58 localePushedBy: '', 59 shortSHA: '', 60 link: '', 61 pusher: { 62 displayName: '', 63 link: '', 64 }, 65 branch: { 66 name: '', 67 link: '', 68 }, 69 } 70 }, 71 currentJob: { 72 title: '', 73 detail: '', 74 steps: [ 75 // { 76 // summary: '', 77 // duration: '', 78 // status: '', 79 // } 80 ], 81 }, 82 }; 83 }, 84 85 async mounted() { 86 // load job data and then auto-reload periodically 87 // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener 88 await this.loadJob(); 89 this.intervalID = setInterval(this.loadJob, 1000); 90 document.body.addEventListener('click', this.closeDropdown); 91 this.hashChangeListener(); 92 window.addEventListener('hashchange', this.hashChangeListener); 93 }, 94 95 beforeUnmount() { 96 document.body.removeEventListener('click', this.closeDropdown); 97 window.removeEventListener('hashchange', this.hashChangeListener); 98 }, 99 100 unmounted() { 101 // clear the interval timer when the component is unmounted 102 // even our page is rendered once, not spa style 103 if (this.intervalID) { 104 clearInterval(this.intervalID); 105 this.intervalID = null; 106 } 107 }, 108 109 methods: { 110 // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group` 111 getLogsContainer(idx) { 112 const el = this.$refs.logs[idx]; 113 return el._stepLogsActiveContainer ?? el; 114 }, 115 // begin a log group 116 beginLogGroup(idx) { 117 const el = this.$refs.logs[idx]; 118 119 const elJobLogGroup = document.createElement('div'); 120 elJobLogGroup.classList.add('job-log-group'); 121 122 const elJobLogGroupSummary = document.createElement('div'); 123 elJobLogGroupSummary.classList.add('job-log-group-summary'); 124 125 const elJobLogList = document.createElement('div'); 126 elJobLogList.classList.add('job-log-list'); 127 128 elJobLogGroup.append(elJobLogGroupSummary); 129 elJobLogGroup.append(elJobLogList); 130 el._stepLogsActiveContainer = elJobLogList; 131 }, 132 // end a log group 133 endLogGroup(idx) { 134 const el = this.$refs.logs[idx]; 135 el._stepLogsActiveContainer = null; 136 }, 137 138 // show/hide the step logs for a step 139 toggleStepLogs(idx) { 140 this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded; 141 if (this.currentJobStepsStates[idx].expanded) { 142 this.loadJob(); // try to load the data immediately instead of waiting for next timer interval 143 } 144 }, 145 // cancel a run 146 cancelRun() { 147 POST(`${this.run.link}/cancel`); 148 }, 149 // approve a run 150 approveRun() { 151 POST(`${this.run.link}/approve`); 152 }, 153 154 createLogLine(line, startTime, stepIndex) { 155 const div = document.createElement('div'); 156 div.classList.add('job-log-line'); 157 div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`); 158 div._jobLogTime = line.timestamp; 159 160 const lineNumber = document.createElement('a'); 161 lineNumber.classList.add('line-num', 'muted'); 162 lineNumber.textContent = line.index; 163 lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`); 164 div.append(lineNumber); 165 166 // for "Show timestamps" 167 const logTimeStamp = document.createElement('span'); 168 logTimeStamp.className = 'log-time-stamp'; 169 const date = new Date(parseFloat(line.timestamp * 1000)); 170 const timeStamp = date.toLocaleString(getCurrentLocale(), {timeZoneName: 'short'}); 171 logTimeStamp.textContent = timeStamp; 172 toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']); 173 // for "Show seconds" 174 const logTimeSeconds = document.createElement('span'); 175 logTimeSeconds.className = 'log-time-seconds'; 176 const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime)); 177 logTimeSeconds.textContent = `${seconds}s`; 178 toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']); 179 180 const logMessage = document.createElement('span'); 181 logMessage.className = 'log-msg'; 182 logMessage.innerHTML = renderAnsi(line.message); 183 div.append(logTimeStamp); 184 div.append(logMessage); 185 div.append(logTimeSeconds); 186 187 return div; 188 }, 189 190 appendLogs(stepIndex, logLines, startTime) { 191 for (const line of logLines) { 192 // TODO: group support: ##[group]GroupTitle , ##[endgroup] 193 const el = this.getLogsContainer(stepIndex); 194 el.append(this.createLogLine(line, startTime, stepIndex)); 195 } 196 }, 197 198 async fetchArtifacts() { 199 const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/artifacts`); 200 return await resp.json(); 201 }, 202 203 async fetchJob() { 204 const logCursors = this.currentJobStepsStates.map((it, idx) => { 205 // cursor is used to indicate the last position of the logs 206 // it's only used by backend, frontend just reads it and passes it back, it and can be any type. 207 // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc 208 return {step: idx, cursor: it.cursor, expanded: it.expanded}; 209 }); 210 const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, { 211 data: {logCursors}, 212 }); 213 return await resp.json(); 214 }, 215 216 async loadJob() { 217 if (this.loading) return; 218 try { 219 this.loading = true; 220 221 let job, artifacts; 222 try { 223 [job, artifacts] = await Promise.all([ 224 this.fetchJob(), 225 this.fetchArtifacts(), // refresh artifacts if upload-artifact step done 226 ]); 227 } catch (err) { 228 if (err instanceof TypeError) return; // avoid network error while unloading page 229 throw err; 230 } 231 232 this.artifacts = artifacts['artifacts'] || []; 233 234 // save the state to Vue data, then the UI will be updated 235 this.run = job.state.run; 236 this.currentJob = job.state.currentJob; 237 238 // sync the currentJobStepsStates to store the job step states 239 for (let i = 0; i < this.currentJob.steps.length; i++) { 240 if (!this.currentJobStepsStates[i]) { 241 // initial states for job steps 242 this.currentJobStepsStates[i] = {cursor: null, expanded: false}; 243 } 244 } 245 // append logs to the UI 246 for (const logs of job.logs.stepsLog) { 247 // save the cursor, it will be passed to backend next time 248 this.currentJobStepsStates[logs.step].cursor = logs.cursor; 249 this.appendLogs(logs.step, logs.lines, logs.started); 250 } 251 252 if (this.run.done && this.intervalID) { 253 clearInterval(this.intervalID); 254 this.intervalID = null; 255 } 256 } finally { 257 this.loading = false; 258 } 259 }, 260 261 isDone(status) { 262 return ['success', 'skipped', 'failure', 'cancelled'].includes(status); 263 }, 264 265 closeDropdown() { 266 if (this.menuVisible) this.menuVisible = false; 267 }, 268 269 toggleTimeDisplay(type) { 270 this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`]; 271 for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) { 272 toggleElem(el, this.timeVisible[`log-time-${type}`]); 273 } 274 }, 275 276 toggleFullScreen() { 277 this.isFullScreen = !this.isFullScreen; 278 const fullScreenEl = document.querySelector('.action-view-right'); 279 const outerEl = document.querySelector('.full.height'); 280 const actionBodyEl = document.querySelector('.action-view-body'); 281 const headerEl = document.querySelector('#navbar'); 282 const contentEl = document.querySelector('.page-content.repository'); 283 const footerEl = document.querySelector('.page-footer'); 284 toggleElem(headerEl, !this.isFullScreen); 285 toggleElem(contentEl, !this.isFullScreen); 286 toggleElem(footerEl, !this.isFullScreen); 287 // move .action-view-right to new parent 288 if (this.isFullScreen) { 289 outerEl.append(fullScreenEl); 290 } else { 291 actionBodyEl.append(fullScreenEl); 292 } 293 }, 294 async hashChangeListener() { 295 const selectedLogStep = window.location.hash; 296 if (!selectedLogStep) return; 297 const [_, step, _line] = selectedLogStep.split('-'); 298 if (!this.currentJobStepsStates[step]) return; 299 if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) { 300 this.currentJobStepsStates[step].expanded = true; 301 // need to await for load job if the step log is loaded for the first time 302 // so logline can be selected by querySelector 303 await this.loadJob(); 304 } 305 const logLine = this.$refs.steps.querySelector(selectedLogStep); 306 if (!logLine) return; 307 logLine.querySelector('.line-num').click(); 308 } 309 }, 310 }; 311 312 export default sfc; 313 314 export function initRepositoryActionView() { 315 const el = document.getElementById('repo-action-view'); 316 if (!el) return; 317 318 // TODO: the parent element's full height doesn't work well now, 319 // but we can not pollute the global style at the moment, only fix the height problem for pages with this component 320 const parentFullHeight = document.querySelector('body > div.full.height'); 321 if (parentFullHeight) parentFullHeight.style.paddingBottom = '0'; 322 323 const view = createApp(sfc, { 324 runIndex: el.getAttribute('data-run-index'), 325 jobIndex: el.getAttribute('data-job-index'), 326 actionsURL: el.getAttribute('data-actions-url'), 327 locale: { 328 approve: el.getAttribute('data-locale-approve'), 329 cancel: el.getAttribute('data-locale-cancel'), 330 rerun: el.getAttribute('data-locale-rerun'), 331 artifactsTitle: el.getAttribute('data-locale-artifacts-title'), 332 rerun_all: el.getAttribute('data-locale-rerun-all'), 333 showTimeStamps: el.getAttribute('data-locale-show-timestamps'), 334 showLogSeconds: el.getAttribute('data-locale-show-log-seconds'), 335 showFullScreen: el.getAttribute('data-locale-show-full-screen'), 336 downloadLogs: el.getAttribute('data-locale-download-logs'), 337 status: { 338 unknown: el.getAttribute('data-locale-status-unknown'), 339 waiting: el.getAttribute('data-locale-status-waiting'), 340 running: el.getAttribute('data-locale-status-running'), 341 success: el.getAttribute('data-locale-status-success'), 342 failure: el.getAttribute('data-locale-status-failure'), 343 cancelled: el.getAttribute('data-locale-status-cancelled'), 344 skipped: el.getAttribute('data-locale-status-skipped'), 345 blocked: el.getAttribute('data-locale-status-blocked'), 346 }, 347 } 348 }); 349 view.mount(el); 350 } 351 </script> 352 <template> 353 <div class="ui container action-view-container"> 354 <div class="action-view-header"> 355 <div class="action-info-summary"> 356 <div class="action-info-summary-title"> 357 <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/> 358 <h2 class="action-info-summary-title-text"> 359 {{ run.title }} 360 </h2> 361 </div> 362 <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove"> 363 {{ locale.approve }} 364 </button> 365 <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel"> 366 {{ locale.cancel }} 367 </button> 368 <button class="ui basic small compact button gt-mr-0 link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun"> 369 {{ locale.rerun_all }} 370 </button> 371 </div> 372 <div class="action-commit-summary"> 373 {{ run.commit.localeCommit }} 374 <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a> 375 {{ run.commit.localePushedBy }} 376 <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a> 377 <span class="ui label" v-if="run.commit.shortSHA"> 378 <a :href="run.commit.branch.link">{{ run.commit.branch.name }}</a> 379 </span> 380 </div> 381 </div> 382 <div class="action-view-body"> 383 <div class="action-view-left"> 384 <div class="job-group-section"> 385 <div class="job-brief-list"> 386 <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1"> 387 <div class="job-brief-item-left"> 388 <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/> 389 <span class="job-brief-name gt-mx-3 gt-ellipsis">{{ job.name }}</span> 390 </div> 391 <span class="job-brief-item-right"> 392 <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun gt-mx-3 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/> 393 <span class="step-summary-duration">{{ job.duration }}</span> 394 </span> 395 </a> 396 </div> 397 </div> 398 <div class="job-artifacts" v-if="artifacts.length > 0"> 399 <div class="job-artifacts-title"> 400 {{ locale.artifactsTitle }} 401 </div> 402 <ul class="job-artifacts-list"> 403 <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name"> 404 <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> 405 <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} 406 </a> 407 </li> 408 </ul> 409 </div> 410 </div> 411 412 <div class="action-view-right"> 413 <div class="job-info-header"> 414 <div class="job-info-header-left"> 415 <h3 class="job-info-header-title"> 416 {{ currentJob.title }} 417 </h3> 418 <p class="job-info-header-detail"> 419 {{ currentJob.detail }} 420 </p> 421 </div> 422 <div class="job-info-header-right"> 423 <div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible"> 424 <button class="btn gt-interact-bg gt-p-3"> 425 <SvgIcon name="octicon-gear" :size="18"/> 426 </button> 427 <div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak> 428 <a class="item" @click="toggleTimeDisplay('seconds')"> 429 <i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i> 430 {{ locale.showLogSeconds }} 431 </a> 432 <a class="item" @click="toggleTimeDisplay('stamp')"> 433 <i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i> 434 {{ locale.showTimeStamps }} 435 </a> 436 <a class="item" @click="toggleFullScreen()"> 437 <i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i> 438 {{ locale.showFullScreen }} 439 </a> 440 <div class="divider"/> 441 <a :class="['item', currentJob.steps.length === 0 ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank"> 442 <i class="icon"><SvgIcon name="octicon-download"/></i> 443 {{ locale.downloadLogs }} 444 </a> 445 </div> 446 </div> 447 </div> 448 </div> 449 <div class="job-step-container" ref="steps"> 450 <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i"> 451 <div class="job-step-summary" @click.stop="toggleStepLogs(i)" :class="currentJobStepsStates[i].expanded ? 'selected' : ''"> 452 <!-- If the job is done and the job step log is loaded for the first time, show the loading icon 453 currentJobStepsStates[i].cursor === null means the log is loaded for the first time 454 --> 455 <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="gt-mr-3 job-status-rotate"/> 456 <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" class="gt-mr-3"/> 457 <ActionRunStatus :status="jobStep.status" class="gt-mr-3"/> 458 459 <span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span> 460 <span class="step-summary-duration">{{ jobStep.duration }}</span> 461 </div> 462 463 <!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM, 464 use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. --> 465 <div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/> 466 </div> 467 </div> 468 </div> 469 </div> 470 </div> 471 </template> 472 <style scoped> 473 .action-view-body { 474 padding-top: 12px; 475 padding-bottom: 12px; 476 display: flex; 477 gap: 12px; 478 } 479 480 /* ================ */ 481 /* action view header */ 482 483 .action-view-header { 484 margin-top: 8px; 485 } 486 487 .action-info-summary { 488 display: flex; 489 align-items: center; 490 justify-content: space-between; 491 } 492 493 .action-info-summary-title { 494 display: flex; 495 } 496 497 .action-info-summary-title-text { 498 font-size: 20px; 499 margin: 0 0 0 8px; 500 flex: 1; 501 } 502 503 .action-commit-summary { 504 display: flex; 505 gap: 5px; 506 margin: 0 0 0 28px; 507 } 508 509 /* ================ */ 510 /* action view left */ 511 512 .action-view-left { 513 width: 30%; 514 max-width: 400px; 515 position: sticky; 516 top: 0; 517 max-height: 100vh; 518 overflow-y: auto; 519 } 520 521 .job-artifacts-title { 522 font-size: 18px; 523 margin-top: 16px; 524 padding: 16px 10px 0 20px; 525 border-top: 1px solid var(--color-secondary); 526 } 527 528 .job-artifacts-item { 529 margin: 5px 0; 530 padding: 6px; 531 } 532 533 .job-artifacts-list { 534 padding-left: 12px; 535 list-style: none; 536 } 537 538 .job-artifacts-icon { 539 padding-right: 3px; 540 } 541 542 .job-brief-list { 543 display: flex; 544 flex-direction: column; 545 gap: 8px; 546 } 547 548 .job-brief-item { 549 padding: 10px; 550 border-radius: var(--border-radius); 551 text-decoration: none; 552 display: flex; 553 flex-wrap: nowrap; 554 justify-content: space-between; 555 align-items: center; 556 color: var(--color-text); 557 } 558 559 .job-brief-item:hover { 560 background-color: var(--color-hover); 561 } 562 563 .job-brief-item.selected { 564 font-weight: var(--font-weight-bold); 565 background-color: var(--color-active); 566 } 567 568 .job-brief-item:first-of-type { 569 margin-top: 0; 570 } 571 572 .job-brief-item .job-brief-rerun { 573 cursor: pointer; 574 transition: transform 0.2s; 575 } 576 577 .job-brief-item .job-brief-rerun:hover { 578 transform: scale(130%); 579 } 580 581 .job-brief-item .job-brief-item-left { 582 display: flex; 583 width: 100%; 584 min-width: 0; 585 } 586 587 .job-brief-item .job-brief-item-left span { 588 display: flex; 589 align-items: center; 590 } 591 592 .job-brief-item .job-brief-item-left .job-brief-name { 593 display: block; 594 width: 70%; 595 } 596 597 .job-brief-item .job-brief-item-right { 598 display: flex; 599 align-items: center; 600 } 601 602 /* ================ */ 603 /* action view right */ 604 605 .action-view-right { 606 flex: 1; 607 color: var(--color-console-fg-subtle); 608 max-height: 100%; 609 width: 70%; 610 display: flex; 611 flex-direction: column; 612 } 613 614 /* begin fomantic button overrides */ 615 616 .action-view-right .ui.button, 617 .action-view-right .ui.button:focus { 618 background: transparent; 619 color: var(--color-console-fg-subtle); 620 } 621 622 .action-view-right .ui.button:hover { 623 background: var(--color-console-hover-bg); 624 color: var(--color-console-fg); 625 } 626 627 .action-view-right .ui.button:active { 628 background: var(--color-console-active-bg); 629 color: var(--color-console-fg); 630 } 631 632 /* end fomantic button overrides */ 633 634 /* begin fomantic dropdown menu overrides */ 635 636 .action-view-right .ui.dropdown .menu { 637 background: var(--color-console-menu-bg); 638 border-color: var(--color-console-menu-border); 639 } 640 641 .action-view-right .ui.dropdown .menu > .item { 642 color: var(--color-console-fg); 643 } 644 645 .action-view-right .ui.dropdown .menu > .item:hover { 646 color: var(--color-console-fg); 647 background: var(--color-console-hover-bg); 648 } 649 650 .action-view-right .ui.dropdown .menu > .item:active { 651 color: var(--color-console-fg); 652 background: var(--color-console-active-bg); 653 } 654 655 .action-view-right .ui.dropdown .menu > .divider { 656 border-top-color: var(--color-console-menu-border); 657 } 658 659 .action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after { 660 background: var(--color-console-menu-bg); 661 box-shadow: -1px -1px 0 0 var(--color-console-menu-border); 662 } 663 664 /* end fomantic dropdown menu overrides */ 665 666 .job-info-header { 667 display: flex; 668 justify-content: space-between; 669 align-items: center; 670 padding: 0 12px; 671 border-bottom: 1px solid var(--color-console-border); 672 background-color: var(--color-console-bg); 673 position: sticky; 674 top: 0; 675 border-radius: var(--border-radius) var(--border-radius) 0 0; 676 height: 60px; 677 z-index: 1; 678 } 679 680 .job-info-header .job-info-header-title { 681 color: var(--color-console-fg); 682 font-size: 16px; 683 margin: 0; 684 } 685 686 .job-info-header .job-info-header-detail { 687 color: var(--color-console-fg-subtle); 688 font-size: 12px; 689 } 690 691 .job-step-container { 692 background-color: var(--color-console-bg); 693 max-height: 100%; 694 border-radius: 0 0 var(--border-radius) var(--border-radius); 695 z-index: 0; 696 } 697 698 .job-step-container .job-step-summary { 699 cursor: pointer; 700 padding: 5px 10px; 701 display: flex; 702 align-items: center; 703 border-radius: var(--border-radius); 704 } 705 706 .job-step-container .job-step-summary .step-summary-msg { 707 flex: 1; 708 } 709 710 .job-step-container .job-step-summary .step-summary-duration { 711 margin-left: 16px; 712 } 713 714 .job-step-container .job-step-summary:hover { 715 color: var(--color-console-fg); 716 background-color: var(--color-console-hover-bg); 717 718 } 719 720 .job-step-container .job-step-summary.selected { 721 color: var(--color-console-fg); 722 background-color: var(--color-console-active-bg); 723 position: sticky; 724 top: 60px; 725 } 726 727 @media (max-width: 768px) { 728 .action-view-body { 729 flex-direction: column; 730 } 731 .action-view-left, .action-view-right { 732 width: 100%; 733 } 734 735 .action-view-left { 736 max-width: none; 737 overflow-y: hidden; 738 } 739 } 740 </style> 741 742 <style> 743 /* some elements are not managed by vue, so we need to use global style */ 744 .job-status-rotate { 745 animation: job-status-rotate-keyframes 1s linear infinite; 746 } 747 748 @keyframes job-status-rotate-keyframes { 749 100% { 750 transform: rotate(360deg); 751 } 752 } 753 754 .job-step-section { 755 margin: 10px; 756 } 757 758 .job-step-section .job-step-logs { 759 font-family: var(--fonts-monospace); 760 margin: 8px 0; 761 font-size: 12px; 762 } 763 764 .job-step-section .job-step-logs .job-log-line { 765 display: flex; 766 } 767 768 .job-log-line:hover, 769 .job-log-line:target { 770 background-color: var(--color-console-hover-bg); 771 } 772 773 .job-log-line:target { 774 scroll-margin-top: 95px; 775 } 776 777 /* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */ 778 .job-log-line .line-num, .log-time-seconds { 779 width: 48px; 780 color: var(--color-grey-light); 781 text-align: right; 782 user-select: none; 783 } 784 785 .job-log-line:target > .line-num { 786 color: var(--color-primary); 787 text-decoration: underline; 788 } 789 790 .log-time-seconds { 791 padding-right: 2px; 792 } 793 794 .job-log-line .log-time, 795 .log-time-stamp { 796 color: var(--color-grey-light); 797 margin-left: 10px; 798 white-space: nowrap; 799 } 800 801 .job-step-section .job-step-logs .job-log-line .log-msg { 802 flex: 1; 803 word-break: break-all; 804 white-space: break-spaces; 805 margin-left: 10px; 806 } 807 808 /* selectors here are intentionally exact to only match fullscreen */ 809 810 .full.height > .action-view-right { 811 width: 100%; 812 height: 100%; 813 padding: 0; 814 border-radius: 0; 815 } 816 817 .full.height > .action-view-right > .job-info-header { 818 border-radius: 0; 819 } 820 821 .full.height > .action-view-right > .job-step-container { 822 height: calc(100% - 60px); 823 border-radius: 0; 824 } 825 826 /* TODO: group support 827 828 .job-log-group { 829 830 } 831 .job-log-group-summary { 832 833 } 834 .job-log-list { 835 836 } */ 837 </style>