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