Initial commit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:19:47 +08:00
commit 3f7bdf0222
49 changed files with 7818 additions and 0 deletions

40
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist/*
!dist/.gitkeep
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

38
web/README.md Normal file
View File

@@ -0,0 +1,38 @@
# web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

0
web/dist/.gitkeep vendored Normal file
View File

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI Manager</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
web/jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

2836
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && touch dist/.gitkeep",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.17.0",
"element-plus": "^2.14.2",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

179
web/src/App.vue Normal file
View File

@@ -0,0 +1,179 @@
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar-brand" @click="router.push('/')">
<div class="brand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</div>
<span class="brand-text">CI Hub</span>
</div>
<nav class="sidebar-nav">
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }">
<span class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</span>
<span class="nav-label">Projects</span>
</router-link>
</nav>
<div class="sidebar-footer">
<button class="btn-new-project" @click="router.push('/projects/new')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Project
</button>
</div>
</aside>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template>
<style>
*,
*::before,
*::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app {
height: 100%;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f0f2f5;
}
/* Page transition */
.page-enter-active,
.page-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.page-enter-from { opacity: 0; transform: translateY(8px); }
.page-leave-to { opacity: 0; transform: translateY(-8px); }
</style>
<style scoped>
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Sidebar ── */
.sidebar {
width: 240px;
flex-shrink: 0;
background: linear-gradient(180deg, #1a1d2e 0%, #16192a 100%);
color: #c8ccd8;
display: flex;
flex-direction: column;
user-select: none;
border-right: 1px solid #ffffff08;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 22px 20px 18px;
cursor: pointer;
border-bottom: 1px solid #ffffff0a;
}
.brand-icon {
width: 36px; height: 36px;
background: linear-gradient(135deg, #409eff, #337ecc);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.brand-icon svg { width: 20px; height: 20px; }
.brand-text {
font-size: 18px;
font-weight: 700;
color: #e8eaef;
letter-spacing: -0.3px;
}
/* ── Nav ── */
.sidebar-nav {
flex: 1;
padding: 12px 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
color: #9ca0b0;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
margin-bottom: 2px;
}
.nav-item:hover {
background: #ffffff08;
color: #d0d4e0;
}
.nav-item.active {
background: #ffffff0f;
color: #fff;
font-weight: 600;
}
.nav-icon svg { width: 18px; height: 18px; display: block; }
/* ── Footer button ── */
.sidebar-footer {
padding: 16px 14px 20px;
border-top: 1px solid #ffffff0a;
}
.btn-new-project {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
background: linear-gradient(135deg, #409eff, #337ecc);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-new-project:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px #409eff40;
}
.btn-new-project:active { transform: translateY(0); }
.btn-new-project svg { width: 16px; height: 16px; }
/* ── Main ── */
.main-content {
flex: 1;
overflow-y: auto;
padding: 28px 32px;
background: #f0f2f5;
}
</style>

90
web/src/api/index.js Normal file
View File

@@ -0,0 +1,90 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
// --- Projects ---
export function listProjects() {
return api.get('/projects').then(r => r.data)
}
export function getProject(id) {
return api.get(`/projects/${id}`).then(r => r.data)
}
export function createProject(data) {
return api.post('/projects', data).then(r => r.data)
}
export function updateProject(id, data) {
return api.put(`/projects/${id}`, data).then(r => r.data)
}
export function deleteProject(id) {
return api.delete(`/projects/${id}`).then(r => r.data)
}
// --- Build & Run ---
export function triggerBuild(id) {
return api.post(`/projects/${id}/build`).then(r => r.data)
}
export function startProject(id) {
return api.post(`/projects/${id}/start`).then(r => r.data)
}
export function stopProject(id) {
return api.post(`/projects/${id}/stop`).then(r => r.data)
}
export function restartProject(id) {
return api.post(`/projects/${id}/restart`).then(r => r.data)
}
export function getBuilds(id) {
return api.get(`/projects/${id}/builds`).then(r => r.data)
}
export function getBuildLog(id, buildId) {
return api.get(`/projects/${id}/logs/${buildId}`).then(r => r.data)
}
// --- Upload ---
export function uploadFile(id, formData) {
return api.post(`/projects/${id}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data)
}
// --- Deploy Config ---
export function getDeployConfig(id) {
return api.get(`/projects/${id}/deploy-config`).then(r => r.data)
}
export function updateDeployConfig(id, data) {
return api.put(`/projects/${id}/deploy-config`, data).then(r => r.data)
}
export function deleteDeployConfig(id) {
return api.delete(`/projects/${id}/deploy-config`).then(r => r.data)
}
// --- Deploy Execution ---
export function triggerDeploy(id) {
return api.post(`/projects/${id}/deploy`).then(r => r.data)
}
export function getDeployRecords(id) {
return api.get(`/projects/${id}/deploy-records`).then(r => r.data)
}
export function getDeployLog(id, deployId) {
return api.get(`/projects/${id}/deploy-records/${deployId}/log`).then(r => r.data)
}
// --- SSE ---
export function createLogStream(id) {
return new EventSource(`/api/projects/${id}/logs/stream`)
}

86
web/src/assets/base.css Normal file
View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
web/src/assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

35
web/src/assets/main.css Normal file
View File

@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

View File

@@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

10
web/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.mount('#app')

17
web/src/router/index.js Normal file
View File

@@ -0,0 +1,17 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import ProjectCreate from '../views/ProjectCreate.vue'
import ProjectDetail from '../views/ProjectDetail.vue'
const routes = [
{ path: '/', name: 'Dashboard', component: Dashboard },
{ path: '/projects/new', name: 'ProjectCreate', component: ProjectCreate },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetail, props: true },
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router

250
web/src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,250 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listProjects, deleteProject } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const projects = ref([])
const loading = ref(true)
const fetchProjects = async () => {
try {
projects.value = await listProjects()
} catch (e) {
ElMessage.error('Failed to load projects')
} finally {
loading.value = false
}
}
const handleDelete = async (project) => {
try {
await ElMessageBox.confirm(
`Delete project "${project.name}"? This cannot be undone.`,
'Confirm Delete',
{ type: 'warning', confirmButtonText: 'Delete', cancelButtonText: 'Cancel' }
)
await deleteProject(project.id)
ElMessage.success('Project deleted')
fetchProjects()
} catch (e) { /* cancelled */ }
}
const statusTag = (status) => {
const map = { running: 'success', stopped: 'info', building: 'warning', error: 'danger' }
return map[status] || 'info'
}
const statusLabel = (status) => {
const map = { running: 'Running', stopped: 'Stopped', building: 'Building...', error: 'Error' }
return map[status] || status
}
onMounted(fetchProjects)
</script>
<template>
<div class="dashboard">
<header class="page-header">
<div>
<h1 class="page-title">Projects</h1>
<p class="page-subtitle">{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }} managed</p>
</div>
<el-button type="primary" size="large" @click="router.push('/projects/new')" round>
<span style="font-size:18px;line-height:1">+</span>&nbsp; New Project
</el-button>
</header>
<el-skeleton :loading="loading" :count="3" animated />
<div v-if="!loading && projects.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>
<h3>No projects yet</h3>
<p>Create your first project to start building and deploying.</p>
<el-button type="primary" @click="router.push('/projects/new')">Create Project</el-button>
</div>
<div v-if="!loading && projects.length > 0" class="project-grid">
<article
v-for="p in projects"
:key="p.id"
class="project-card"
@click="router.push(`/projects/${p.id}`)"
>
<div class="card-top">
<div class="card-status-row">
<span class="status-dot" :class="'dot-' + p.status"></span>
<span class="status-text">{{ statusLabel(p.status) }}</span>
</div>
<el-tag size="small" :type="p.mode === 'git' ? '' : 'warning'" effect="plain" round>
{{ p.mode === 'git' ? 'Git' : 'Upload' }}
</el-tag>
</div>
<h3 class="card-title">{{ p.name }}</h3>
<p class="card-desc">{{ p.description || 'No description' }}</p>
<div class="card-meta">
<span v-if="p.git_url" class="meta-item" :title="p.git_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
{{ p.git_url }}
</span>
<span v-if="p.port" class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
:{{ p.port }}
</span>
</div>
<div class="card-actions" @click.stop>
<el-button size="small" round @click="router.push(`/projects/${p.id}`)">Detail</el-button>
<el-button size="small" type="danger" round plain @click="handleDelete(p)">Delete</el-button>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.dashboard { max-width: 1200px; }
/* ── Header ── */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
}
.page-title {
font-size: 26px;
font-weight: 700;
color: #1a1d2e;
letter-spacing: -0.5px;
}
.page-subtitle {
font-size: 14px;
color: #8c8f9a;
margin-top: 4px;
}
/* ── Empty ── */
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-icon {
width: 72px; height: 72px;
margin: 0 auto 20px;
background: #e8ecf1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #a0a4b0;
}
.empty-icon svg { width: 32px; height: 32px; }
.empty-state h3 { font-size: 18px; color: #333; margin-bottom: 6px; }
.empty-state p { font-size: 14px; color: #999; margin-bottom: 20px; }
/* ── Grid ── */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
/* ── Card ── */
.project-card {
background: #fff;
border-radius: 14px;
padding: 22px 24px 18px;
cursor: pointer;
border: 1px solid #ebeef2;
transition: all 0.2s ease;
box-shadow: 0 1px 3px #00000005;
}
.project-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px #0000000d;
border-color: #d0d7e2;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.card-status-row {
display: flex;
align-items: center;
gap: 7px;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-running { background: #67c23a; box-shadow: 0 0 6px #67c23a60; }
.dot-stopped { background: #909399; }
.dot-building { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
.dot-error { background: #f56c6c; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.status-text {
font-size: 13px;
color: #666;
font-weight: 500;
}
.card-title {
font-size: 17px;
font-weight: 700;
color: #1a1d2e;
margin-bottom: 6px;
letter-spacing: -0.2px;
}
.card-desc {
font-size: 13px;
color: #8c8f9a;
min-height: 36px;
line-height: 1.5;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #a0a4b0;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-item svg { width: 13px; height: 13px; flex-shrink: 0; }
.card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
}
</style>

View File

@@ -0,0 +1,219 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { createProject } from '../api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const form = ref({
name: '',
description: '',
mode: 'git',
git_url: '',
git_branch: 'master',
git_username: '',
git_password: '',
build_script: '',
run_command: '',
output_binary: '',
env_vars: '',
port: null,
})
const submitting = ref(false)
const handleSubmit = async () => {
if (!form.value.name) {
ElMessage.warning('Project name is required')
return
}
submitting.value = true
try {
await createProject(form.value)
ElMessage.success('Project created')
router.push('/')
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Failed to create project')
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="create-page">
<el-page-header @back="router.push('/')" class="page-back">
<template #content>
<span class="back-title">New Project</span>
</template>
</el-page-header>
<el-card class="form-card" shadow="never">
<el-form :model="form" label-position="top" class="project-form">
<!-- Basic info -->
<div class="form-section">
<h3 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
Basic Information
</h3>
<div class="form-row two-col">
<el-form-item label="Project Name" required>
<el-input v-model="form.name" placeholder="my-project" size="large" />
</el-form-item>
<el-form-item label="Port">
<el-input-number v-model="form.port" :min="1" :max="65535" placeholder="8080" size="large" style="width:100%" />
</el-form-item>
</div>
<el-form-item label="Description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="A short description of this project..." />
</el-form-item>
</div>
<!-- Source -->
<div class="form-section">
<h3 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Source Code
</h3>
<el-form-item label="Source Mode">
<el-radio-group v-model="form.mode" class="mode-switch">
<el-radio-button value="git">
<span class="radio-inner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9"/><path d="M12 12v3"/></svg>
Git Repository
</span>
</el-radio-button>
<el-radio-button value="upload">
<span class="radio-inner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
File Upload
</span>
</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="form.mode === 'git'">
<el-form-item label="Git URL">
<el-input v-model="form.git_url" placeholder="https://gitee.com/user/repo.git" size="large" />
</el-form-item>
<div class="form-row two-col">
<el-form-item label="Branch">
<el-input v-model="form.git_branch" placeholder="master" />
</el-form-item>
<el-form-item label="Username (private repos)">
<el-input v-model="form.git_username" placeholder="optional" />
</el-form-item>
</div>
<el-form-item label="Password / Token">
<el-input v-model="form.git_password" type="password" placeholder="optional" show-password />
</el-form-item>
</template>
<template v-if="form.mode === 'upload'">
<el-alert type="info" :closable="false" show-icon>
After creating this project, go to its detail page to upload your source files as .zip or .tar.gz.
</el-alert>
</template>
</div>
<!-- Build & Run -->
<div class="form-section">
<h3 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" y1="19" x2="19" y2="13"/><line x1="16" y1="16" x2="20" y2="20"/><line x1="19" y1="21" x2="21" y2="19"/></svg>
Build &amp; Run Configuration
</h3>
<el-form-item label="Build Script">
<el-input v-model="form.build_script" type="textarea" :rows="3"
placeholder="go build -o app ." />
</el-form-item>
<div class="form-row two-col">
<el-form-item label="Output Binary">
<el-input v-model="form.output_binary" placeholder="app" />
</el-form-item>
<el-form-item label="Run Command">
<el-input v-model="form.run_command" placeholder="./app" />
</el-form-item>
</div>
<el-form-item label="Environment Variables">
<el-input v-model="form.env_vars" type="textarea" :rows="2"
placeholder='{"DB_HOST": "192.168.1.100", "DB_PORT": "3306"}' />
</el-form-item>
</div>
<div class="form-actions">
<el-button size="large" @click="router.push('/')">Cancel</el-button>
<el-button type="primary" size="large" :loading="submitting" @click="handleSubmit">
Create Project
</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.create-page { max-width: 760px; margin: 0 auto; }
.page-back { margin-bottom: 20px; }
.back-title { font-size: 20px; font-weight: 700; color: #1a1d2e; }
.form-card {
border-radius: 14px;
border: 1px solid #ebeef2;
box-shadow: 0 1px 3px #00000005;
}
.form-card :deep(.el-card__body) { padding: 28px 32px; }
/* ── Sections ── */
.form-section {
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
}
.form-section:last-of-type { border-bottom: none; margin-bottom: 24px; padding-bottom: 0; }
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #1a1d2e;
margin-bottom: 16px;
}
.section-title svg { width: 18px; height: 18px; color: #409eff; }
.form-row { display: flex; gap: 16px; }
.form-row.two-col > * { flex: 1; }
/* ── Mode switch ── */
.mode-switch { width: 100%; }
.mode-switch :deep(.el-radio-button) { flex: 1; }
.mode-switch :deep(.el-radio-button__inner) {
width: 100%;
padding: 14px 20px;
font-weight: 500;
}
.radio-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.radio-inner svg { width: 16px; height: 16px; }
/* ── Form items ── */
.project-form :deep(.el-form-item__label) {
font-weight: 600;
color: #444;
padding-bottom: 4px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 8px;
}
</style>

View File

@@ -0,0 +1,687 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import {
getProject, updateProject, triggerBuild, startProject, stopProject, restartProject,
getBuilds, uploadFile, createLogStream,
getDeployConfig, updateDeployConfig, deleteDeployConfig, triggerDeploy, getDeployRecords, getDeployLog
} from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps({ id: String })
const router = useRouter()
const project = ref(null)
const builds = ref([])
const logs = ref([])
const logContainer = ref(null)
const activeTab = ref('overview')
const loading = ref(true)
const actionLoading = ref(false)
const editingSettings = ref(false)
const settingsForm = ref({})
const uploadRef = ref(null)
const deployConfig = ref(null)
const deployRecords = ref([])
const editingDeploy = ref(false)
const deployForm = ref({
host: '', port: 22, username: '', auth_method: 'key',
ssh_key: '', ssh_password: '',
deploy_dir: '', file_mappings: '',
pre_deploy_command: '', post_deploy_command: '',
health_check_url: '', health_check_timeout: 30,
auto_deploy: false,
})
let sse = null
const projectId = () => parseInt(props.id)
const fetchProject = async () => {
try {
project.value = await getProject(projectId())
} catch (e) {
ElMessage.error('Project not found')
router.push('/')
} finally {
loading.value = false
}
}
const fetchBuilds = async () => {
try { builds.value = await getBuilds(projectId()) } catch (e) { /* ignore */ }
}
const connectLogStream = () => {
if (sse) sse.close()
sse = createLogStream(projectId())
sse.onmessage = (event) => {
logs.value.push(event.data)
if (logs.value.length > 1000) logs.value.shift()
setTimeout(() => {
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
}, 50)
}
sse.onerror = () => {}
}
const doBuild = async () => {
actionLoading.value = true
try {
await triggerBuild(projectId())
ElMessage.success('Build started')
logs.value = []
activeTab.value = 'logs'
connectLogStream()
fetchDeployRecords()
pollStatus(30000)
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Build failed')
} finally {
actionLoading.value = false
}
}
const doStart = async () => {
actionLoading.value = true
try {
await startProject(projectId())
ElMessage.success('Project started')
fetchProject()
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Failed to start')
} finally { actionLoading.value = false }
}
const doStop = async () => {
actionLoading.value = true
try {
await stopProject(projectId())
ElMessage.success('Project stopped')
fetchProject()
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Failed to stop')
} finally { actionLoading.value = false }
}
const doRestart = async () => {
actionLoading.value = true
try {
await restartProject(projectId())
ElMessage.success('Project restarted')
fetchProject()
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Failed to restart')
} finally { actionLoading.value = false }
}
const doUpload = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
await uploadFile(projectId(), formData)
ElMessage.success('File uploaded and extracted')
fetchProject()
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Upload failed')
}
}
const saveSettings = async () => {
try {
await updateProject(projectId(), settingsForm.value)
ElMessage.success('Settings saved')
editingSettings.value = false
fetchProject()
} catch (e) {
ElMessage.error('Failed to save settings')
}
}
const fetchDeployConfig = async () => {
try {
const cfg = await getDeployConfig(projectId())
if (cfg && cfg.id) deployConfig.value = cfg
} catch (e) {}
}
const fetchDeployRecords = async () => {
try { deployRecords.value = await getDeployRecords(projectId()) } catch (e) {}
}
const saveDeployConfig = async () => {
try {
await updateDeployConfig(projectId(), deployForm.value)
ElMessage.success('Deploy config saved')
editingDeploy.value = false
fetchDeployConfig()
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Failed to save deploy config')
}
}
const removeDeployConfig = async () => {
try {
await ElMessageBox.confirm('Remove deploy configuration?', 'Confirm', { type: 'warning' })
await deleteDeployConfig(projectId())
ElMessage.success('Deploy config removed')
deployConfig.value = null
deployRecords.value = []
} catch (e) {}
}
const doDeploy = async () => {
actionLoading.value = true
try {
await triggerDeploy(projectId())
ElMessage.success('Deployment started')
logs.value = []
activeTab.value = 'logs'
connectLogStream()
setTimeout(() => { fetchProject(); fetchDeployRecords() }, 3000)
} catch (e) {
ElMessage.error(e.response?.data?.error || 'Failed to start deploy')
} finally { actionLoading.value = false }
}
const viewDeployLog = async (deployId) => {
try {
const log = await getDeployLog(projectId(), deployId)
logs.value = log.split('\n')
activeTab.value = 'logs'
setTimeout(() => {
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
}, 100)
} catch (e) {
ElMessage.error('Failed to load deploy log')
}
}
const deployStatusTag = (s) => {
const map = { running: 'warning', success: 'success', failed: 'danger' }
return map[s] || 'info'
}
const pollStatus = (duration) => {
const interval = setInterval(async () => {
try {
const p = await getProject(projectId())
project.value = p
if (p.status !== 'building') {
clearInterval(interval)
fetchBuilds()
}
} catch (e) { clearInterval(interval) }
}, 2000)
setTimeout(() => clearInterval(interval), duration)
}
const statusTag = (s) => {
const map = { running: 'success', stopped: 'info', building: 'warning', error: 'danger' }
return map[s] || 'info'
}
onMounted(() => {
fetchProject()
fetchBuilds()
fetchDeployConfig()
fetchDeployRecords()
connectLogStream()
})
onUnmounted(() => { if (sse) sse.close() })
</script>
<template>
<div v-loading="loading" class="detail-page">
<el-page-header @back="router.push('/')" class="page-back">
<template #content>
<span v-if="project" class="back-title">{{ project.name }}</span>
</template>
</el-page-header>
<div v-if="project">
<!-- ==== Action Bar ==== -->
<div class="action-bar">
<div class="action-bar-left">
<span class="status-badge" :class="'badge-' + project.status">
<span class="badge-dot"></span>
{{ project.status }}
</span>
<span class="mode-badge">{{ project.mode === 'git' ? 'Git' : 'Upload' }}</span>
</div>
<div class="action-bar-right">
<el-button-group class="btn-group">
<el-button type="primary" :loading="actionLoading" @click="doBuild" :disabled="project.status === 'building'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" y1="19" x2="19" y2="13"/><line x1="16" y1="16" x2="20" y2="20"/><line x1="19" y1="21" x2="21" y2="19"/></svg>
Build
</el-button>
<el-button type="success" :loading="actionLoading" @click="doStart" :disabled="project.status === 'running'">
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" class="btn-icon"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Start
</el-button>
<el-button type="warning" :loading="actionLoading" @click="doStop" :disabled="project.status !== 'running'">
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" class="btn-icon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
Stop
</el-button>
<el-button :loading="actionLoading" @click="doRestart" :disabled="project.status !== 'running'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
Restart
</el-button>
</el-button-group>
<el-button
v-if="deployConfig"
type="primary"
:loading="actionLoading"
@click="doDeploy"
class="btn-deploy"
plain
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
Deploy
</el-button>
</div>
</div>
<!-- ==== Tabs ==== -->
<el-tabs v-model="activeTab" class="main-tabs">
<!-- Overview -->
<el-tab-pane label="Overview" name="overview">
<div class="overview-grid">
<el-card class="ov-card" shadow="never">
<template #header><span class="card-header-title">Project Info</span></template>
<el-descriptions :column="1" border>
<el-descriptions-item label="Status">
<el-tag :type="statusTag(project.status)" effect="dark" size="small">{{ project.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Mode">{{ project.mode }}</el-descriptions-item>
<el-descriptions-item label="PID">{{ project.pid || '—' }}</el-descriptions-item>
<el-descriptions-item label="Port">{{ project.port || '—' }}</el-descriptions-item>
<el-descriptions-item label="Created">{{ new Date(project.created_at).toLocaleString() }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="ov-card" shadow="never" v-if="project.mode === 'git'">
<template #header><span class="card-header-title">Git Repository</span></template>
<el-descriptions :column="1" border>
<el-descriptions-item label="URL">{{ project.git_url || '—' }}</el-descriptions-item>
<el-descriptions-item label="Branch">{{ project.git_branch || 'master' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="ov-card" shadow="never">
<template #header><span class="card-header-title">Build Config</span></template>
<el-descriptions :column="1" border>
<el-descriptions-item label="Build Script">{{ project.build_script || 'go build -o app .' }}</el-descriptions-item>
<el-descriptions-item label="Output Binary">{{ project.output_binary || 'app' }}</el-descriptions-item>
<el-descriptions-item label="Run Command">{{ project.run_command || './app' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<el-card v-if="project.mode === 'upload'" shadow="never" class="upload-card">
<template #header><span class="card-header-title">Upload Source Files</span></template>
<el-upload ref="uploadRef" drag :auto-upload="false" :on-change="doUpload" :limit="1" accept=".zip,.tar.gz,.tgz">
<div class="upload-area">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="upload-icon"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p>Drop .zip or .tar.gz here or click to upload</p>
</div>
</el-upload>
</el-card>
</el-tab-pane>
<!-- Logs -->
<el-tab-pane label="Logs" name="logs">
<div class="log-console" ref="logContainer">
<div v-if="logs.length === 0" class="log-empty">
<span class="log-prompt">Waiting for output...</span>
</div>
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="{ 'log-deploy': line.startsWith('[Deploy]'), 'log-error': line.includes('ERROR') || line.includes('FAILED') || line.includes('failed'), 'log-success': line.includes('SUCCESS') || line.includes('completed successfully') }">{{ line }}</div>
</div>
</el-tab-pane>
<!-- Build History -->
<el-tab-pane label="Build History" name="builds">
<el-card shadow="never">
<el-table :data="builds" stripe class="history-table">
<el-table-column prop="id" label="#" width="60" align="center" />
<el-table-column prop="status" label="Status" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'running' ? 'warning' : 'danger'" size="small" effect="dark" round>
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Commit" width="120">
<template #default="{ row }">
<code v-if="row.commit_hash" class="commit-hash">{{ (row.commit_hash || '').slice(0, 7) }}</code>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="Started" width="180">
<template #default="{ row }">{{ new Date(row.started_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="Duration">
<template #default="{ row }">
<span v-if="row.finished_at">{{ Math.round((new Date(row.finished_at) - new Date(row.started_at)) / 1000) }}s</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="Log" width="100" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="activeTab = 'logs'">View</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="builds.length === 0" description="No builds yet" :image-size="80" />
</el-card>
</el-tab-pane>
<!-- Settings -->
<el-tab-pane label="Settings" name="settings">
<el-card shadow="never">
<template #header>
<div class="card-header-row">
<span class="card-header-title">Project Settings</span>
<el-button v-if="!editingSettings" type="primary" plain size="small" round @click="editingSettings = true; settingsForm = { ...project }">Edit</el-button>
</div>
</template>
<el-form v-if="editingSettings" :model="settingsForm" label-position="top">
<el-form-item label="Name"><el-input v-model="settingsForm.name" /></el-form-item>
<el-form-item label="Description"><el-input v-model="settingsForm.description" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="project.mode === 'git'" label="Git URL"><el-input v-model="settingsForm.git_url" /></el-form-item>
<el-form-item v-if="project.mode === 'git'" label="Branch"><el-input v-model="settingsForm.git_branch" /></el-form-item>
<el-form-item label="Build Script"><el-input v-model="settingsForm.build_script" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="Run Command"><el-input v-model="settingsForm.run_command" /></el-form-item>
<el-form-item label="Port"><el-input-number v-model="settingsForm.port" /></el-form-item>
<el-form-item label="Env Vars"><el-input v-model="settingsForm.env_vars" type="textarea" :rows="2" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSettings">Save</el-button>
<el-button @click="editingSettings = false">Cancel</el-button>
</el-form-item>
</el-form>
<el-descriptions v-else :column="1" border>
<el-descriptions-item label="Name">{{ project.name }}</el-descriptions-item>
<el-descriptions-item label="Description">{{ project.description || '—' }}</el-descriptions-item>
<el-descriptions-item label="Mode">{{ project.mode }}</el-descriptions-item>
<el-descriptions-item v-if="project.mode === 'git'" label="Git URL">{{ project.git_url || '' }}</el-descriptions-item>
<el-descriptions-item label="Build Script">{{ project.build_script || 'go build -o app .' }}</el-descriptions-item>
<el-descriptions-item label="Run Command">{{ project.run_command || './app' }}</el-descriptions-item>
<el-descriptions-item label="Port">{{ project.port || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
<!-- Deploy -->
<el-tab-pane label="Deploy" name="deploy">
<el-card class="deploy-config-card" shadow="never">
<template #header>
<div class="card-header-row">
<span class="card-header-title">Deploy Configuration</span>
<div class="header-actions">
<el-button v-if="!editingDeploy && deployConfig" type="primary" plain size="small" round @click="editingDeploy = true; deployForm = { ...deployConfig, ssh_key: '', ssh_password: '' }">Edit</el-button>
<el-button v-if="!editingDeploy && !deployConfig" type="primary" size="small" round @click="editingDeploy = true">Add Config</el-button>
</div>
</div>
</template>
<el-form v-if="editingDeploy" :model="deployForm" label-position="top" class="deploy-form">
<div class="form-section">
<h4 class="section-subtitle">SSH Connection</h4>
<div class="form-row three-col">
<el-form-item label="Host" required><el-input v-model="deployForm.host" placeholder="192.168.1.100" /></el-form-item>
<el-form-item label="Port"><el-input-number v-model="deployForm.port" :min="1" :max="65535" style="width:100%" /></el-form-item>
<el-form-item label="Username" required><el-input v-model="deployForm.username" placeholder="root" /></el-form-item>
</div>
<el-form-item label="Auth Method">
<el-radio-group v-model="deployForm.auth_method">
<el-radio value="key">SSH Key</el-radio>
<el-radio value="password">Password</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="deployForm.auth_method === 'key'" label="SSH Private Key">
<el-input v-model="deployForm.ssh_key" type="textarea" :rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----" />
</el-form-item>
<el-form-item v-if="deployForm.auth_method === 'password'" label="SSH Password">
<el-input v-model="deployForm.ssh_password" type="password" placeholder="SSH password" show-password />
</el-form-item>
</div>
<div class="form-section">
<h4 class="section-subtitle">Deployment</h4>
<el-form-item label="Deploy Directory" required><el-input v-model="deployForm.deploy_dir" placeholder="/opt/myapp" /></el-form-item>
<el-form-item label="File Mappings">
<el-input v-model="deployForm.file_mappings" type="textarea" :rows="3" placeholder='[{"local":"app","remote":"/opt/myapp/app"}]' />
</el-form-item>
<div class="form-row two-col">
<el-form-item label="Pre-deploy Command"><el-input v-model="deployForm.pre_deploy_command" placeholder="systemctl stop myapp" /></el-form-item>
<el-form-item label="Post-deploy Command"><el-input v-model="deployForm.post_deploy_command" placeholder="systemctl start myapp" /></el-form-item>
</div>
</div>
<div class="form-section">
<h4 class="section-subtitle">Health Check</h4>
<div class="form-row two-col">
<el-form-item label="Health Check URL"><el-input v-model="deployForm.health_check_url" placeholder="http://192.168.1.100:8080/health" /></el-form-item>
<el-form-item label="Timeout (seconds)"><el-input-number v-model="deployForm.health_check_timeout" :min="5" :max="120" style="width:100%" /></el-form-item>
</div>
</div>
<div class="form-section">
<h4 class="section-subtitle">Automation</h4>
<el-form-item label="Auto-deploy after successful build">
<el-switch v-model="deployForm.auto_deploy" active-text="On" inactive-text="Off" />
</el-form-item>
</div>
<div class="form-actions">
<el-button @click="editingDeploy = false">Cancel</el-button>
<el-button v-if="deployConfig" type="danger" plain @click="removeDeployConfig">Remove Config</el-button>
<el-button type="primary" @click="saveDeployConfig">Save Configuration</el-button>
</div>
</el-form>
<el-descriptions v-else-if="deployConfig" :column="1" border>
<el-descriptions-item label="SSH">{{ deployConfig.username }}@{{ deployConfig.host }}:{{ deployConfig.port }}</el-descriptions-item>
<el-descriptions-item label="Auth">{{ deployConfig.auth_method === 'key' ? 'SSH Key' : 'Password' }}</el-descriptions-item>
<el-descriptions-item label="Deploy Directory">{{ deployConfig.deploy_dir }}</el-descriptions-item>
<el-descriptions-item label="Pre-deploy">{{ deployConfig.pre_deploy_command || '—' }}</el-descriptions-item>
<el-descriptions-item label="Post-deploy">{{ deployConfig.post_deploy_command || '—' }}</el-descriptions-item>
<el-descriptions-item label="Health Check">{{ deployConfig.health_check_url || '—' }}</el-descriptions-item>
<el-descriptions-item label="Auto-deploy">{{ deployConfig.auto_deploy ? 'Enabled' : 'Disabled' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="No deploy configuration yet. Click 'Add Config' to set up SSH deployment." :image-size="80" />
</el-card>
<el-card shadow="never" class="deploy-history-card">
<template #header><span class="card-header-title">Deploy History</span></template>
<el-table :data="deployRecords" stripe class="history-table">
<el-table-column prop="id" label="#" width="60" align="center" />
<el-table-column label="Status" width="110">
<template #default="{ row }">
<el-tag :type="deployStatusTag(row.status)" size="small" effect="dark" round>{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Started" width="180">
<template #default="{ row }">{{ new Date(row.started_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="Duration">
<template #default="{ row }">
<span v-if="row.finished_at">{{ Math.round((new Date(row.finished_at) - new Date(row.started_at)) / 1000) }}s</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="Build #" width="90" align="center">
<template #default="{ row }">{{ row.build_record_id || '—' }}</template>
</el-table-column>
<el-table-column label="Log" width="100" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="viewDeployLog(row.id)">View</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="deployRecords.length === 0" description="No deployments yet" :image-size="80" />
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<style scoped>
.detail-page { max-width: 1100px; }
.page-back { margin-bottom: 16px; }
.back-title { font-size: 20px; font-weight: 700; color: #1a1d2e; }
/* ════ Action Bar ════ */
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 18px 22px;
background: #fff;
border-radius: 14px;
border: 1px solid #ebeef2;
margin-bottom: 20px;
box-shadow: 0 1px 3px #00000004;
}
.action-bar-left { display: flex; align-items: center; gap: 12px; }
.action-bar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.status-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.badge-dot { width: 7px; height: 7px; border-radius: 50%; }
.badge-running { background: #eaf7e2; color: #3d8c1e; }
.badge-running .badge-dot { background: #67c23a; }
.badge-stopped { background: #f0f1f3; color: #6a6e78; }
.badge-stopped .badge-dot { background: #909399; }
.badge-building { background: #fef5e7; color: #b07816; }
.badge-building .badge-dot { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
.badge-error { background: #fef0f0; color: #c03639; }
.badge-error .badge-dot { background: #f56c6c; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
.mode-badge {
padding: 4px 12px;
border-radius: 14px;
font-size: 12px;
font-weight: 500;
background: #e8ecf1;
color: #606470;
}
.btn-group .el-button { padding: 8px 16px; }
.btn-icon { width: 15px; height: 15px; margin-right: 4px; vertical-align: -2px; }
.btn-deploy {
border-color: #409eff;
color: #409eff;
font-weight: 600;
}
.btn-deploy:hover { background: #409eff10; }
/* ════ Tabs ════ */
.main-tabs :deep(.el-tabs__header) { margin-bottom: 18px; }
.main-tabs :deep(.el-tabs__item) {
font-size: 14px;
font-weight: 500;
padding: 0 20px;
}
/* ════ Overview ════ */
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.ov-card { border-radius: 12px; border: 1px solid #ebeef2; }
.upload-card { margin-top: 0; border-radius: 12px; border: 1px solid #ebeef2; }
.upload-area { padding: 10px 0; text-align: center; color: #909399; }
.upload-icon { width: 52px; height: 52px; color: #c0c4cc; margin-bottom: 8px; }
/* ════ Cards ════ */
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
.card-header-title { font-size: 15px; font-weight: 700; color: #1a1d2e; }
.header-actions { display: flex; gap: 8px; }
/* ════ Log Console ════ */
.log-console {
background: #161821;
color: #c8d1e0;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
padding: 18px 20px;
border-radius: 12px;
min-height: 480px;
max-height: 560px;
overflow-y: auto;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
.log-console::-webkit-scrollbar { width: 6px; }
.log-console::-webkit-scrollbar-track { background: transparent; }
.log-console::-webkit-scrollbar-thumb { background: #3a3d4a; border-radius: 3px; }
.log-empty { color: #5a5e6e; padding: 20px 0; text-align: center; }
.log-prompt::before { content: '▎'; animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
.log-deploy { color: #7dcfff; }
.log-error { color: #f7768e; font-weight: 500; }
.log-success { color: #9ece6a; }
/* ════ Tables ════ */
.history-table { border-radius: 8px; overflow: hidden; }
.commit-hash {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
background: #f0f1f3;
padding: 2px 8px;
border-radius: 4px;
color: #409eff;
}
.text-muted { color: #c0c4cc; }
/* ════ Deploy config ════ */
.deploy-config-card { border-radius: 12px; border: 1px solid #ebeef2; margin-bottom: 16px; }
.deploy-history-card { border-radius: 12px; border: 1px solid #ebeef2; }
.deploy-form .form-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f5f5f5;
}
.deploy-form .form-section:last-of-type { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.section-subtitle {
font-size: 13px;
font-weight: 700;
color: #409eff;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #409eff15;
}
.form-row { display: flex; gap: 16px; }
.form-row > * { flex: 1; }
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 12px;
}
</style>

21
web/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})

8
web/web.iml Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>