diff --git a/README.md b/README.md index 4bc3218..a579ec1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 功能 - **Supervisor 面板** —— 通过 supervisord 托管的进程:启停、保活、查看日志、端口归属 -- **应用市场 / 应用管理** —— 基于本机 Kubernetes 的容器化应用一键安装与管理(可选) +- **应用市场 / 应用管理** —— Docker Compose 与本机 Kubernetes 双运行时;支持内联 Compose、平台商店和第三方 HTTP/Git catalog(可选) - **本地模型** —— 模型目录扫描、容量、可用 runtime 视图 - **文件浏览器** —— 工作区文件浏览 - **Web 终端** —— 浏览器内交互式 shell @@ -59,7 +59,7 @@ pkg/ config/ 配置加载与校验 console/ 本地 HTTP / WebSocket 服务(控制台后端) supervisor/ supervisord 客户端封装 - apps/ K8s 应用管理 + 应用市场客户端 + apps/ Compose/K8s 应用领域、异步任务与应用市场 collector/ 系统 / GPU / 设备指标采集 alerts/ 本地告警规则引擎 files/ 文件浏览 diff --git a/cmd/devbox/main.go b/cmd/devbox/main.go index 462dc8e..f77b45d 100644 --- a/cmd/devbox/main.go +++ b/cmd/devbox/main.go @@ -6,7 +6,9 @@ import ( "fmt" "os" "os/signal" + "path/filepath" "syscall" + "time" "github.com/a2d2-dev/devbox/pkg/apps" "github.com/a2d2-dev/devbox/pkg/collector" @@ -49,16 +51,28 @@ func main() { col := collector.New(logger, version) go col.Start(ctx) - // K8s 应用管理器:可选,初始化失败仅禁用相关功能。 - var appMgr *apps.Manager - if mgr, err := apps.NewManager(logger, apps.Config{ - Kubeconfig: cfg.Kubernetes.Kubeconfig, - Namespace: cfg.Kubernetes.Namespace, - }); err != nil { - logger.Warn("K8s app manager unavailable; app management disabled", zap.Error(err)) + // 应用管理 Controller(统一 K8s + Docker Compose 运行时,Issue #2)。 + // 装配失败仅禁用应用管理,不影响控制台其它功能。 + var appController apps.Controller + var appCleanup func() + if c, cleanup, err := apps.AssembleController(ctx, apps.ControllerConfig{ + DataDir: cfg.Compose.DataDir, + DockerSocket: cfg.Compose.DockerSocket, + ComposeEnabled: cfg.Compose.Enabled, + Kubeconfig: cfg.Kubernetes.Kubeconfig, + Namespace: cfg.Kubernetes.Namespace, + KubernetesEnabled: true, + }, logger); err != nil { + logger.Warn("App controller unavailable; app management disabled", zap.Error(err)) } else { - appMgr = mgr + appController = c + appCleanup = cleanup } + defer func() { + if appCleanup != nil { + appCleanup() + } + }() // 应用商店管理器:仅在显式配置 APIServerURL 时启用。 var storeMgr *apps.StoreManager @@ -74,18 +88,41 @@ func main() { } } + // Catalog source 聚合:启动 YAML 来源 + apps.db 动态 1Panel 来源。 + // 两者共用同一 SQLite 事实源;YAML 来源只读且优先。 + configuredSources := toCatalogSources(cfg.Compose.Catalogs) + cacheRoot := filepath.Join(cfg.Compose.DataDir, "catalog-cache") + catalogs := apps.NewCatalogSetFromConfigs(configuredSources, cacheRoot, logger) + var catalogSourceManager *apps.CatalogSourceManager + if err := os.MkdirAll(cfg.Compose.DataDir, 0o750); err != nil { + logger.Warn("Catalog source data directory unavailable", zap.Error(err)) + } else if sourceRepo, err := apps.OpenRepository(ctx, apps.CatalogDBPath(cfg.Compose.DataDir)); err != nil { + logger.Warn("Dynamic catalog source storage unavailable", zap.Error(err)) + } else { + defer sourceRepo.Close() + catalogSourceManager = apps.NewCatalogSourceManager(sourceRepo, configuredSources, catalogs, cacheRoot, logger) + if err := catalogSourceManager.Reload(ctx); err != nil { + logger.Warn("Dynamic catalog sources load failed", zap.Error(err)) + } + } + poll := time.Duration(cfg.Compose.CatalogPoll) * time.Second + go catalogs.Start(ctx, poll) + logger.Info("Catalog sources started", zap.Int("configured_sources", len(cfg.Compose.Catalogs)), zap.Duration("poll_interval", poll)) + consoleServer := console.NewServer(logger, console.Config{ - Enabled: cfg.Console.Enabled, - Port: cfg.Console.Port, - StaticDir: cfg.Console.StaticDir, - WorkDir: cfg.Console.WorkDir, - SupervisorSocket: cfg.Console.SupervisorSocket, - SupervisorConfDir: cfg.Console.SupervisorConfDir, - ConsoleURL: cfg.Console.ConsoleURL, - AuthPassword: cfg.Auth.Password, - AuthSessionTTL: cfg.Auth.SessionTTL, - LinksPath: cfg.Console.LinksPath, - }, col, appMgr, storeMgr) + Enabled: cfg.Console.Enabled, + Port: cfg.Console.Port, + StaticDir: cfg.Console.StaticDir, + WorkDir: cfg.Console.WorkDir, + SupervisorSocket: cfg.Console.SupervisorSocket, + SupervisorConfDir: cfg.Console.SupervisorConfDir, + ConsoleURL: cfg.Console.ConsoleURL, + AuthPassword: cfg.Auth.Password, + AuthSessionTTL: cfg.Auth.SessionTTL, + LinksPath: cfg.Console.LinksPath, + Catalogs: catalogs, + CatalogSourceManager: catalogSourceManager, + }, col, appController, storeMgr) go func() { if err := consoleServer.Start(ctx); err != nil { @@ -129,3 +166,24 @@ func initLogger(cfg config.LoggingConfig) (*zap.Logger, error) { return zapCfg.Build() } + +// toCatalogSources 把 config.CatalogSourceConfig 映射为 apps.CatalogSource。 +// 字段一一对应;token 作为 secret 透传(不入日志/审计;git 经 http.extraHeader 注入)。 +func toCatalogSources(cfgs []config.CatalogSourceConfig) []apps.CatalogSource { + out := make([]apps.CatalogSource, 0, len(cfgs)) + for _, c := range cfgs { + out = append(out, apps.CatalogSource{ + ID: c.ID, + Name: c.Name, + Kind: c.Kind, + URL: c.URL, + Platform: c.Platform, + Host: c.Host, + Ref: c.Ref, + Path: c.Path, + Token: c.Token, + Insecure: c.Insecure, + }) + } + return out +} diff --git a/config.yaml.example b/config.yaml.example index 7294e70..bc931a3 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -27,6 +27,36 @@ kubernetes: # 应用市场 API 地址;留空则禁用应用市场 apiserver_url: "" +# Docker Compose 应用管理;Docker 不可用时仅该运行时降级。 +compose: + enabled: true + data_dir: "/var/lib/devbox" + # 仅允许本机绝对 Unix socket;不支持 tcp/http/远程 Docker endpoint。 + # devbox 会固定 CLI 到这个 socket,不继承 DOCKER_HOST/DOCKER_CONTEXT。 + docker_socket: "/var/run/docker.sock" + # 第三方 catalog 刷新间隔(秒);0 表示仅启动/手动刷新。 + catalog_poll: 300 + catalogs: + # HTTP 文件源:读取 /catalog.json 及其相对 compose 文件。生产应使用 HTTPS。 + # - id: "team-http" + # name: "团队应用" + # kind: "http" + # url: "https://apps.example.com/devbox" + # Git 仓库源:HTTPS shallow clone;不接受 SSH、file:// 或本地路径。 + # - id: "community" + # name: "社区 Compose" + # kind: "git" + # url: "https://github.com/example/devbox-catalog.git" + # ref: "v1" + # path: "catalog" + # 原生 1Panel 开源应用商店;ref 留空使用远端默认分支(官方源当前为 dev)。 + # - id: "onepanel-official" + # name: "1Panel 官方商店" + # kind: "1panel" + # url: "https://github.com/1Panel-dev/appstore" + # 私有源可配置只读 token;建议通过受控配置文件注入并限制文件权限。 + # token: "" + logging: # debug / info / warn / error level: "info" diff --git a/console-ui/package.json b/console-ui/package.json index 144d8b1..35ff60c 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test src/lib/compose.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/console-ui/src/App.jsx b/console-ui/src/App.jsx index eadca0a..9a9a7e4 100644 --- a/console-ui/src/App.jsx +++ b/console-ui/src/App.jsx @@ -41,6 +41,7 @@ import { Dock } from './components/Dock' import AppWindow, { btnSecondary, btnPrimary } from './components/AppWindow' import DashboardApp from './pages/Dashboard' import AppStore from './pages/AppStore' +import { ComposeManager } from './pages/ComposeManager' import AlertCenter from './pages/AlertCenter' import AuditLog from './pages/AuditLog' import Supervisor from './pages/Supervisor' @@ -423,6 +424,8 @@ export default function App() { > {appId === 'dashboard' && } {appId === 'store' && } + {appId === 'compose-manager' && launchApp({ id: 'store' })} onOpenApp={launchApp}/>} {appId === 'alerts' && } {appId === 'audit' && } {appId === 'supervisor'&& } @@ -430,7 +433,7 @@ export default function App() { {appId === 'hardware' && } {appId === 'links' && } {(appId === 'diag' || appId === 'settings') && } - {!['dashboard','store','alerts','audit','supervisor','virtual-machines','hardware','links','diag','settings'].includes(appId) + {!['dashboard','store','compose-manager','alerts','audit','supervisor','virtual-machines','hardware','links','diag','settings'].includes(appId) && setMgmtOpen(true)}/>} diff --git a/console-ui/src/components/AppMgmtDrawer.jsx b/console-ui/src/components/AppMgmtDrawer.jsx index aa66639..30d0732 100644 --- a/console-ui/src/components/AppMgmtDrawer.jsx +++ b/console-ui/src/components/AppMgmtDrawer.jsx @@ -1,16 +1,21 @@ -import { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from 'react' +import { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback, useId } from 'react' import { useAnimationControls, useDragControls, useMotionValue, useTransform } from 'motion/react' import { T } from '../tokens' import { Icon } from '../icons' -import { StatusDot, Chip, Sparkline } from '../components/ui' -import { useAppLogs, useAppVersions, switchAppVersion, appOp, deleteApp, useAppDetail } from '../hooks/useApi' +import { StatusDot, Chip } from '../components/ui' +import { useAppLogs, useAppVersions, switchAppVersion, appOp, deleteApp, useAppDetail, appActionAsync, useTask } from '../hooks/useApi' import { motion, project, springs, useMotionPref } from '../motion' import { btnSecondary, btnPrimary, btnDanger } from './AppWindow' import TabBar from './TabBar' +import UninstallDialog from './UninstallDialog' +import { + ComposeOverview, ComposeServices, ComposeLogs, ComposeEditor, ComposeEnv, + ComposeStorage, ComposeRevisions, ComposeOperations, +} from './ComposeMgmtPanels' // Story 4.7:「容器 Shell」tab + 渲染分支在 merge commit 693efd5 中被吞,2026-06-22 恢复 import ContainerShellFace from './ContainerShellFace' -function KpiCell({ label, value, unit, tone, mono }) { +function KpiCell({ label, value, tone, mono }) { return (
- 'mgrad-' + Math.random().toString(36).slice(2, 9), []); + const gradId = 'mgrad-' + useId().replaceAll(':', ''); const chartH = 96; const padL = 32, padR = 6, padT = 8, padB = 18; @@ -304,8 +308,8 @@ function MgmtMetrics({ app, metricsData, historyData }) { return rates; }, [historyData.netBytesRecv, historyData.timestamps]); - const timestamps = historyData.timestamps || []; const timeLabels = useMemo(() => { + const timestamps = historyData.timestamps || []; if (!timestamps.length) return ['', '', '', '', '']; const step = Math.max(1, Math.floor((timestamps.length - 1) / 5)); const labels = []; @@ -314,7 +318,7 @@ function MgmtMetrics({ app, metricsData, historyData }) { labels.push(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`); } return labels; - }, [timestamps]); + }, [historyData.timestamps]); const safeArr = (s) => s && s.length > 0 ? s : [0]; const last = (s) => { const a = safeArr(s); return a[a.length - 1]; }; @@ -511,7 +515,7 @@ resources: ${appSummary.resources}`; fontSize: 11, lineHeight: 1.65, whiteSpace: 'pre', overflow: 'auto', maxHeight: 360, }}>{configText.split('\n').map((line, i) => { - const m = line.match(/^(\s*)([\w\.\/-]+)(:.*)$/); + const m = line.match(/^(\s*)([\w./-]+)(:.*)$/); if (m) return
{m[1]} {m[2]} @@ -1011,9 +1015,11 @@ function UninstallProgress({ app }) { ); } -export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAuth, onUninstall, metricsData, historyData }) { +export default function AppMgmtDrawer({ app, open, onClose, onUninstall, metricsData, historyData }) { const [tab, setTab] = useState('overview'); const [uninstall, setUninstall] = useState(null); // null | 'confirm' | 'running' | 'done' + const [operationTaskId, setOperationTaskId] = useState(null); + const { task: operationTask } = useTask(operationTaskId); const pref = useMotionPref(); const drawerRef = useRef(null); const drawerWidthRef = useRef(980); @@ -1093,12 +1099,33 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut if (!app) return null; - const isError = app.state === 'error'; - const stateCfg = isError - ? { tone: 'red', label: '运行异常', dot: 'red', pulse: true } - : { tone: 'green', label: '运行中', dot: 'green', pulse: false }; - - const tabs = [ + const isCompose = app.runtime === 'compose'; + const phase = isCompose ? (app.observed?.phase || 'unknown') : (app.state === 'error' ? 'failed' : 'running'); + const isError = phase === 'failed' || phase === 'degraded'; + const composeState = { + running: { tone: 'green', label: '运行中', dot: 'green', pulse: false }, + degraded: { tone: 'amber', label: '运行降级', dot: 'yellow', pulse: true }, + failed: { tone: 'red', label: '运行异常', dot: 'red', pulse: true }, + stopped: { tone: 'gray', label: '已停止', dot: 'gray', pulse: false }, + pending: { tone: 'blue', label: '等待中', dot: 'blue', pulse: true }, + deploying: { tone: 'blue', label: '部署中', dot: 'blue', pulse: true }, + removing: { tone: 'amber', label: '卸载中', dot: 'yellow', pulse: true }, + unknown: { tone: 'gray', label: '未知', dot: 'gray', pulse: false }, + }; + const stateCfg = isCompose ? (composeState[phase] || composeState.unknown) : isError + ? { tone: 'red', label: '运行异常', dot: 'red', pulse: true } + : { tone: 'green', label: '运行中', dot: 'green', pulse: false }; + + const tabs = isCompose ? [ + { id: 'overview', label: '概览', icon: 'info' }, + { id: 'services', label: '服务', icon: 'apps' }, + { id: 'logs', label: '日志', icon: 'terminal' }, + { id: 'compose', label: 'Compose', icon: 'code' }, + { id: 'env', label: '环境变量', icon: 'gear' }, + { id: 'storage', label: '存储', icon: 'database' }, + { id: 'revisions', label: '版本', icon: 'refresh' }, + { id: 'operations', label: '操作记录', icon: 'clock' }, + ] : [ { id: 'overview', label: '概览', icon: 'info' }, { id: 'metrics', label: '指标', icon: 'dashboard' }, { id: 'logs', label: '日志', icon: 'terminal' }, @@ -1106,6 +1133,7 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut { id: 'versions', label: '版本', icon: 'refresh' }, { id: 'config', label: '配置', icon: 'gear' }, ]; + const activeTab = tabs.some((item) => item.id === tab) ? tab : 'overview'; return ( <> @@ -1192,7 +1220,7 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut
- {app.version} + {isCompose ? `Docker Compose · r${app.revision || 0}` : app.version} · {app.category || '应用'} · @@ -1211,10 +1239,10 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut {/* Tabs */} ( <> {t2.label} @@ -1225,12 +1253,32 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut {/* Body */}
- {tab === 'overview' && } - {tab === 'metrics' && } - {tab === 'logs' && } - {tab === 'shell' && } - {tab === 'versions' && } - {tab === 'config' && setUninstall('confirm')}/>} + {isCompose && operationTask && ( +
+ 操作任务 {operationTask.status || 'queued'} · {operationTask.phase || 'queued'}{operationTask.message ? ` · ${operationTask.message}` : ''} +
+ )} + {isCompose ? ( + <> + {activeTab === 'overview' && } + {activeTab === 'services' && } + {activeTab === 'logs' && } + {activeTab === 'compose' && } + {activeTab === 'env' && } + {activeTab === 'storage' && } + {activeTab === 'revisions' && } + {activeTab === 'operations' && } + + ) : ( + <> + {activeTab === 'overview' && } + {activeTab === 'metrics' && } + {activeTab === 'logs' && } + {activeTab === 'shell' && } + {activeTab === 'versions' && } + {activeTab === 'config' && setUninstall('confirm')}/>} + + )}
{/* Footer actions */} @@ -1240,14 +1288,24 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut display: 'flex', gap: 6, alignItems: 'center', }}>
- {!isError ? ( + {isCompose && ( + + )} + {isCompose && phase === 'stopped' ? ( + + ) : !isError ? ( <> @@ -1262,7 +1320,10 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut {/* Uninstall confirm dialog */} - {uninstall === 'confirm' && ( + {uninstall === 'confirm' && isCompose && ( + setUninstall(null)} onDone={() => { setUninstall(null); onClose(); onUninstall && onUninstall(app); }}/> + )} + {uninstall === 'confirm' && !isCompose && ( setUninstall(null)} onConfirm={async () => { diff --git a/console-ui/src/components/ComposeMgmtPanels.jsx b/console-ui/src/components/ComposeMgmtPanels.jsx new file mode 100644 index 0000000..d308f0f --- /dev/null +++ b/console-ui/src/components/ComposeMgmtPanels.jsx @@ -0,0 +1,166 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { T } from '../tokens'; +import { Chip, StatusDot } from './ui'; +import { btnPrimary, btnSecondary } from './AppWindow'; +import { useToast } from './toastContext'; +import { + useAppLogs, useAppOperations, useAppRevisions, useCompose, useEnv, useStorage, + restoreAppRevision, updateComposeApp, useTask, validateCompose, +} from '../hooks/useApi'; +import { formatDateTime, groupRisks, PHASE_LABEL, PHASE_TONE, volumeMeta, envDisplay } from '../lib/compose'; +import { parseEnv } from '../lib/compose'; + +const pane = { padding: 14 }; +const box = { background: T.surfaceAlt, border: `1px solid ${T.borderSoft}`, borderRadius: 8, padding: 12 }; + +export function ComposeOverview({ app }) { + const phase = app.observed?.phase || 'unknown'; + const conditions = app.observed?.conditions || []; + const endpoints = app.observed?.endpoints || []; + return
+
+ + +
+ {app.observed?.message && {app.observed.message}} +
{endpoints.length ? endpoints.map((e, i) => {e.name || e.url} · {e.url}) : }
+
{conditions.length ? conditions.map((c, i) =>
{c.type || 'Condition'}{c.status}{c.message &&
{c.message}
}
) : }
+
; +} + +export function ComposeServices({ app }) { + const services = app.observed?.services || []; + return
{services.length ? services.map((s) =>
+
+ {s.name} + {s.health || '无健康检查'} + {s.state || 'unknown'} +
+
{s.image || '—'}
+ {!!s.ports?.length &&
{s.ports.map((p, i) => {portLabel(p)})}
} + {s.containerId &&
container {s.containerId.slice(0, 12)}
} +
) : }
; +} + +export function ComposeLogs({ app }) { + const services = app.observed?.services || []; + const [service, setService] = useState(() => services[0]?.name || ''); + const { lines, loading } = useAppLogs(app.id, 3000, 300, service); + const ref = useRef(null); + useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]); + return
Service 日志 + {services.length > 0 && } + 3 秒刷新 · 最近 300 行
+
+ {!lines.length &&
{loading ? '加载日志中…' : '暂无日志'}
} + {lines.map((line, i) =>
{line}
)} +
+
; +} + +export function ComposeEditor({ app }) { + const { data, loading, error, refresh } = useCompose(app.id); + const [draft, setDraft] = useState(null); + const [validation, setValidation] = useState(null); + const [confirm, setConfirm] = useState(false); + const [saving, setSaving] = useState(false); + const [conflict, setConflict] = useState(false); + const [taskId, setTaskId] = useState(null); + const [secrets, setSecrets] = useState(''); + const { task } = useTask(taskId); + const toast = useToast(); + const content = draft ?? data?.compose ?? ''; + const risks = groupRisks(validation?.risks); + const canSave = validation?.ok && risks.blocked.length === 0 && (!risks.confirmation.length || confirm) && !saving; + + async function check() { + setValidation(null); setConfirm(false); setConflict(false); + try { setValidation(await validateCompose({ name: app.name, compose: content, appId: app.id, retainEnvironment: true, secrets: parseEnv(secrets) })); } + catch (e) { toast.err(`预检失败:${e.message}`); } + } + async function save() { + setSaving(true); setConflict(false); + try { + const t = await updateComposeApp({ id: app.id, name: app.name, compose: content, expectedRevision: data.revision, source: app.source, confirmRisky: confirm, retainEnvironment: true, secrets: parseEnv(secrets) }); + setTaskId(t.id); toast.ok('配置更新任务已提交'); + } catch (e) { + if (e.status === 409 || e.reason === 'revision_mismatch') setConflict(true); + else toast.err(`保存失败:${e.message}`); + } finally { setSaving(false); } + } + return
+ 保存前必须通过后端 `docker compose config` 与风险预检;使用 expectedRevision,绝不静默覆盖并发修改。 + {loading && }{error && 无法读取 Compose:{String(error.message || error)}} +