Files
devstar/web_src/js/components/RepoDevcontainerView.vue
xinitx 02baa3b7af !67 增加了重启停止容器、dockerfile方式创建保存容器功能
* change initializeScript path
* Merge branch 'add-dockerfile-method-and-start-stop-container' of https…
* 更新了容器镜像方式的构建、安装和使用方法,但是devcontainer功能还有问题
* fix run postCreateCommand bug
* sh文件方式管理启动脚本
* add restart command and fix bug
* add dockerfile method to create container and save container .restart …
2025-05-07 11:10:30 +00:00

584 lines
15 KiB
Vue

<script>
import {SvgIcon} from '../svg.js';
import ActionRunStatus from './ActionRunStatus.vue';
import {createApp} from 'vue';
import {toggleElem} from '../utils/dom.js';
import {formatDatetime} from '../utils/time.js';
import {renderAnsi} from '../render/ansi.js';
import {GET, POST, DELETE} from '../modules/fetch.js';
const sfc = {
name: 'RepoDevcontainerView',
components: {
SvgIcon,
ActionRunStatus,
},
props: {
outputLink: String,
},
data() {
return {
// internal state
loading: false,
intervalID: null,
currentJobStepsStates: [],
currentDevcontainer: {
title: 'Devcontainer Info',
detail: 'No Devcontainer Created yet',
steps: [
// {
// summary: '',
// duration: '',
// status: '',
// logs:{
//
// },
// }
],
},
};
},
async mounted() {
// load job data and then auto-reload periodically
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
await this.loadJob();
this.intervalID = setInterval(this.loadJob, 3000);
},
unmounted() {
// clear the interval timer when the component is unmounted
// even our page is rendered once, not spa style
if (this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null;
}
},
methods: {
// show/hide the step logs for a step
toggleStepLogs(idx) {
this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
},
async fetchJob() {
const resp = await GET(this.outputLink);
return await resp.json();
},
async loadJob() {
if (this.loading) return;
try {
this.loading = true;
let job;
try {
job = await this.fetchJob();
if(!job){
clearInterval(this.intervalID);
this.intervalID = null;
return;
}
} catch (err) {
if (err instanceof TypeError) return; // avoid network error while unloading page
throw err;
}
if(this.currentDevcontainer.detail == "running" && job.currentDevcontainer.detail == "created"){
location.reload();
}
if((this.currentDevcontainer.detail == "created" || this.currentDevcontainer.detail == "running") && job.currentDevcontainer.detail == "success"){
location.reload();
}
// save the state to Vue data, then the UI will be updated
this.currentDevcontainer = job.currentDevcontainer;
// sync the currentJobStepsStates to store the job step states
if (this.currentDevcontainer.steps != null){
for (let i = 0; i < this.currentDevcontainer.steps.length; i++) {
if (!this.currentJobStepsStates[i]) {
// initial states for job steps
this.currentJobStepsStates[i] = {expanded: false};
}
}
}
if (this.isDone(this.currentDevcontainer.detail) && this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null;
}
} finally {
this.loading = false;
}
},
isDone(status) {
return ['success', 'finished', 'skipped', 'failure', 'cancelled'].includes(status);
},
isExpandable(status) {
return ['success', 'finished', 'running', 'failure', 'cancelled'].includes(status);
},
},
};
export default sfc;
export function initRepositoryDevcontainerView() {
const el = document.querySelector('#repo-devcontainer-view');
if (!el) return;
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
const parentFullHeight = document.querySelector('body > div.full.height');
if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
const view = createApp(sfc, {
outputLink: el.getAttribute('data-outputLink')
})
view.mount(el);
}
</script>
<template>
<div class="ui container action-view-container">
<div class="action-view-body">
<div class="action-view-right">
<div class="job-info-header">
<div class="job-info-header-left gt-ellipsis">
<h3 class="job-info-header-title gt-ellipsis">
{{ currentDevcontainer.title }}
</h3>
<p class="job-info-header-detail">
{{ currentDevcontainer.detail }}
</p>
</div>
</div>
<div class="job-step-container" ref="steps" v-if="currentDevcontainer.steps && currentDevcontainer.steps.length">
<div class="job-step-section" v-for="(jobStep, i) in currentDevcontainer.steps" :key="i">
<div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
-->
<SvgIcon v-if="currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
<span class="step-summary-duration">{{ jobStep.duration }}</span>
</div>
<!-- 使用 v-for 渲染 job-log-line -->
<div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded">
<div class="job-log-line" v-for="(line, index) in jobStep.logs" :key="index" :id="`jobstep-${i}-${line.index}`">
<a class="line-num muted" :href="`#jobstep-${i}-${line.index}`">{{ line.index }}</a>
<span class="log-msg">{{ line.message }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.action-view-body {
padding-top: 12px;
padding-bottom: 12px;
display: flex;
gap: 12px;
}
/* ================ */
/* action view header */
.action-view-header {
margin-top: 8px;
}
.action-info-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.action-info-summary-title {
display: flex;
}
.action-info-summary-title-text {
font-size: 20px;
margin: 0 0 0 8px;
flex: 1;
overflow-wrap: anywhere;
}
.action-commit-summary {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-left: 28px;
}
@media (max-width: 767.98px) {
.action-commit-summary {
margin-left: 0;
margin-top: 8px;
}
}
/* ================ */
/* action view left */
.action-view-left {
width: 30%;
max-width: 400px;
position: sticky;
top: 12px;
max-height: 100vh;
overflow-y: auto;
background: var(--color-body);
z-index: 2; /* above .job-info-header */
}
@media (max-width: 767.98px) {
.action-view-left {
position: static; /* can not sticky because multiple jobs would overlap into right view */
}
}
.job-artifacts-title {
font-size: 18px;
margin-top: 16px;
padding: 16px 10px 0 20px;
border-top: 1px solid var(--color-secondary);
}
.job-artifacts-item {
margin: 5px 0;
padding: 6px;
display: flex;
justify-content: space-between;
}
.job-artifacts-list {
padding-left: 12px;
list-style: none;
}
.job-artifacts-icon {
padding-right: 3px;
}
.job-brief-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.job-brief-item {
padding: 10px;
border-radius: var(--border-radius);
text-decoration: none;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
color: var(--color-text);
}
.job-brief-item:hover {
background-color: var(--color-hover);
}
.job-brief-item.selected {
font-weight: var(--font-weight-bold);
background-color: var(--color-active);
}
.job-brief-item:first-of-type {
margin-top: 0;
}
.job-brief-item .job-brief-rerun {
cursor: pointer;
transition: transform 0.2s;
}
.job-brief-item .job-brief-rerun:hover {
transform: scale(130%);
}
.job-brief-item .job-brief-item-left {
display: flex;
width: 100%;
min-width: 0;
}
.job-brief-item .job-brief-item-left span {
display: flex;
align-items: center;
}
.job-brief-item .job-brief-item-left .job-brief-name {
display: block;
width: 70%;
}
.job-brief-item .job-brief-item-right {
display: flex;
align-items: center;
}
/* ================ */
/* action view right */
.action-view-right {
flex: 1;
color: var(--color-console-fg-subtle);
max-height: 100%;
width: 70%;
display: flex;
flex-direction: column;
border: 1px solid var(--color-console-border);
border-radius: var(--border-radius);
background: var(--color-console-bg);
align-self: flex-start;
}
/* begin fomantic button overrides */
.action-view-right .ui.button,
.action-view-right .ui.button:focus {
background: transparent;
color: var(--color-console-fg-subtle);
}
.action-view-right .ui.button:hover {
background: var(--color-console-hover-bg);
color: var(--color-console-fg);
}
.action-view-right .ui.button:active {
background: var(--color-console-active-bg);
color: var(--color-console-fg);
}
/* end fomantic button overrides */
/* begin fomantic dropdown menu overrides */
.action-view-right .ui.dropdown .menu {
background: var(--color-console-menu-bg);
border-color: var(--color-console-menu-border);
}
.action-view-right .ui.dropdown .menu > .item {
color: var(--color-console-fg);
}
.action-view-right .ui.dropdown .menu > .item:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.action-view-right .ui.dropdown .menu > .item:active {
color: var(--color-console-fg);
background: var(--color-console-active-bg);
}
.action-view-right .ui.dropdown .menu > .divider {
border-top-color: var(--color-console-menu-border);
}
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
background: var(--color-console-menu-bg);
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
}
/* end fomantic dropdown menu overrides */
.job-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
position: sticky;
top: 0;
height: 60px;
z-index: 1; /* above .job-step-container */
background: var(--color-console-bg);
border-radius: 3px;
}
.job-info-header:has(+ .job-step-container) {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.job-info-header .job-info-header-title {
color: var(--color-console-fg);
font-size: 16px;
margin: 0;
}
.job-info-header .job-info-header-detail {
color: var(--color-console-fg-subtle);
font-size: 12px;
}
.job-info-header-left {
flex: 1;
}
.job-step-container {
max-height: 100%;
border-radius: 0 0 var(--border-radius) var(--border-radius);
border-top: 1px solid var(--color-console-border);
z-index: 0;
}
.job-step-container .job-step-summary {
padding: 5px 10px;
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.job-step-container .job-step-summary.step-expandable {
cursor: pointer;
}
.job-step-container .job-step-summary.step-expandable:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.job-step-container .job-step-summary .step-summary-msg {
flex: 1;
}
.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
}
.job-step-container .job-step-summary.selected {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
position: sticky;
top: 60px;
}
@media (max-width: 767.98px) {
.action-view-body {
flex-direction: column;
}
.action-view-left, .action-view-right {
width: 100%;
}
.action-view-left {
max-width: none;
}
}
</style>
<style>
/* some elements are not managed by vue, so we need to use global style */
.job-status-rotate {
animation: job-status-rotate-keyframes 1s linear infinite;
}
@keyframes job-status-rotate-keyframes {
100% {
transform: rotate(-360deg);
}
}
.job-step-section {
margin: 10px;
}
.job-step-section .job-step-logs {
font-family: var(--fonts-monospace);
margin: 8px 0;
font-size: 12px;
}
.job-step-section .job-step-logs .job-log-line {
display: flex;
}
.job-log-line:hover,
.job-log-line:target {
background-color: var(--color-console-hover-bg);
}
.job-log-line:target {
scroll-margin-top: 95px;
}
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds {
width: 48px;
color: var(--color-text-light-3);
text-align: right;
user-select: none;
}
.job-log-line:target > .line-num {
color: var(--color-primary);
text-decoration: underline;
}
.log-time-seconds {
padding-right: 2px;
}
.job-log-line .log-time,
.log-time-stamp {
color: var(--color-text-light-3);
margin-left: 10px;
white-space: nowrap;
}
.job-step-section .job-step-logs .job-log-line .log-msg {
flex: 1;
word-break: break-all;
white-space: break-spaces;
margin-left: 10px;
}
/* selectors here are intentionally exact to only match fullscreen */
.full.height > .action-view-right {
width: 100%;
height: 100%;
padding: 0;
border-radius: 0;
}
.full.height > .action-view-right > .job-info-header {
border-radius: 0;
}
.full.height > .action-view-right > .job-step-container {
height: calc(100% - 60px);
border-radius: 0;
}
/* TODO: group support
.job-log-group {
}
.job-log-group-summary {
}
.job-log-list {
} */
</style>