diff --git a/internal/apps/docker/app.go b/internal/apps/docker/app.go index e4e6e86d..350fba08 100644 --- a/internal/apps/docker/app.go +++ b/internal/apps/docker/app.go @@ -1,7 +1,10 @@ package docker import ( + "encoding/json" "net/http" + "os" + "strings" "github.com/go-chi/chi/v5" @@ -19,6 +22,8 @@ func NewApp() *App { func (s *App) Route(r chi.Router) { r.Get("/config", s.GetConfig) r.Post("/config", s.UpdateConfig) + r.Get("/settings", s.GetSettings) + r.Post("/settings", s.UpdateSettings) } func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) { @@ -50,3 +55,213 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) { service.Success(w, nil) } + +// GetSettings 获取 Docker 设置 +func (s *App) GetSettings(w http.ResponseWriter, r *http.Request) { + configPath := "/etc/docker/daemon.json" + + // 读取配置文件 + content, err := io.Read(configPath) + if err != nil { + // 如果文件不存在,返回默认设置 + if os.IsNotExist(err) { + service.Success(w, Settings{}) + return + } + service.Error(w, http.StatusInternalServerError, "%v", err) + return + } + + // 解析 JSON + var daemonConfig DaemonConfig + if err = json.Unmarshal([]byte(content), &daemonConfig); err != nil { + service.Error(w, http.StatusInternalServerError, "%v", err) + return + } + + // 转换为 Settings 结构 + settings := Settings{ + RegistryMirrors: daemonConfig.RegistryMirrors, + InsecureRegistries: daemonConfig.InsecureRegistries, + LiveRestore: daemonConfig.LiveRestore, + LogDriver: daemonConfig.LogDriver, + Hosts: daemonConfig.Hosts, + DataRoot: daemonConfig.DataRoot, + StorageDriver: daemonConfig.StorageDriver, + DNS: daemonConfig.DNS, + FirewallBackend: daemonConfig.FirewallBackend, + Iptables: daemonConfig.Iptables, + Ip6tables: daemonConfig.Ip6tables, + IpForward: daemonConfig.IpForward, + IPv6: daemonConfig.IPv6, + Bip: daemonConfig.Bip, + } + + // 解析 log-opts + if daemonConfig.LogOpts != nil { + settings.LogOpts = LogOpts{ + MaxSize: daemonConfig.LogOpts["max-size"], + MaxFile: daemonConfig.LogOpts["max-file"], + } + } + + // 从 exec-opts 中提取 cgroup-driver + for _, opt := range daemonConfig.ExecOpts { + if strings.HasPrefix(opt, "native.cgroupdriver=") { + settings.CgroupDriver = strings.TrimPrefix(opt, "native.cgroupdriver=") + break + } + } + + service.Success(w, settings) +} + +// UpdateSettings 更新 Docker 设置 +func (s *App) UpdateSettings(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateSettings](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + configPath := "/etc/docker/daemon.json" + settings := req.Settings + + // 读取现有配置(保留其他字段) + var existingConfig map[string]any + content, err := io.Read(configPath) + if err == nil && content != "" { + if err = json.Unmarshal([]byte(content), &existingConfig); err != nil { + existingConfig = make(map[string]any) + } + } else { + existingConfig = make(map[string]any) + } + + // 更新设置字段 + if len(settings.RegistryMirrors) > 0 { + existingConfig["registry-mirrors"] = settings.RegistryMirrors + } else { + delete(existingConfig, "registry-mirrors") + } + + if len(settings.InsecureRegistries) > 0 { + existingConfig["insecure-registries"] = settings.InsecureRegistries + } else { + delete(existingConfig, "insecure-registries") + } + + if settings.LiveRestore { + existingConfig["live-restore"] = true + } else { + delete(existingConfig, "live-restore") + } + + if settings.LogDriver != "" { + existingConfig["log-driver"] = settings.LogDriver + } else { + delete(existingConfig, "log-driver") + } + + // 日志配置 + if settings.LogOpts.MaxSize != "" || settings.LogOpts.MaxFile != "" { + logOpts := make(map[string]string) + if settings.LogOpts.MaxSize != "" { + logOpts["max-size"] = settings.LogOpts.MaxSize + } + if settings.LogOpts.MaxFile != "" { + logOpts["max-file"] = settings.LogOpts.MaxFile + } + existingConfig["log-opts"] = logOpts + } else { + delete(existingConfig, "log-opts") + } + + // cgroup-driver + if settings.CgroupDriver != "" { + existingConfig["exec-opts"] = []string{"native.cgroupdriver=" + settings.CgroupDriver} + } else { + delete(existingConfig, "exec-opts") + } + + if len(settings.Hosts) > 0 { + existingConfig["hosts"] = settings.Hosts + } else { + delete(existingConfig, "hosts") + } + + if settings.DataRoot != "" { + existingConfig["data-root"] = settings.DataRoot + } else { + delete(existingConfig, "data-root") + } + + if settings.StorageDriver != "" { + existingConfig["storage-driver"] = settings.StorageDriver + } else { + delete(existingConfig, "storage-driver") + } + + if len(settings.DNS) > 0 { + existingConfig["dns"] = settings.DNS + } else { + delete(existingConfig, "dns") + } + + // 防火墙后端 + if settings.FirewallBackend != "" { + existingConfig["firewall-backend"] = settings.FirewallBackend + } else { + delete(existingConfig, "firewall-backend") + } + + if settings.Iptables != nil { + existingConfig["iptables"] = *settings.Iptables + } else { + delete(existingConfig, "iptables") + } + + if settings.Ip6tables != nil { + existingConfig["ip6tables"] = *settings.Ip6tables + } else { + delete(existingConfig, "ip6tables") + } + + if settings.IpForward != nil { + existingConfig["ip-forward"] = *settings.IpForward + } else { + delete(existingConfig, "ip-forward") + } + + if settings.IPv6 != nil { + existingConfig["ipv6"] = *settings.IPv6 + } else { + delete(existingConfig, "ipv6") + } + + if settings.Bip != "" { + existingConfig["bip"] = settings.Bip + } else { + delete(existingConfig, "bip") + } + + // 序列化并写入文件 + newContent, err := json.MarshalIndent(existingConfig, "", " ") + if err != nil { + service.Error(w, http.StatusInternalServerError, "%v", err) + return + } + + if err = io.Write(configPath, string(newContent), 0644); err != nil { + service.Error(w, http.StatusInternalServerError, "%v", err) + return + } + + // 重启 Docker 服务 + if err = systemctl.Restart("docker"); err != nil { + service.Error(w, http.StatusInternalServerError, "%v", err) + return + } + + service.Success(w, nil) +} diff --git a/internal/apps/docker/request.go b/internal/apps/docker/request.go index 33a2f2d5..e96d7e6f 100644 --- a/internal/apps/docker/request.go +++ b/internal/apps/docker/request.go @@ -3,3 +3,34 @@ package docker type UpdateConfig struct { Config string `form:"config" json:"config" validate:"required"` } + +// LogOpts 日志配置选项 +type LogOpts struct { + MaxSize string `json:"max-size,omitempty"` // 日志文件最大大小,如 "10m" + MaxFile string `json:"max-file,omitempty"` // 保存的日志文件份数,如 "3" +} + +// Settings Docker daemon 设置 +type Settings struct { + RegistryMirrors []string `json:"registry-mirrors,omitempty"` // 注册表镜像 + InsecureRegistries []string `json:"insecure-registries,omitempty"` // 非安全镜像仓库 + LiveRestore bool `json:"live-restore,omitempty"` // Live restore + LogDriver string `json:"log-driver,omitempty"` // 日志驱动 + LogOpts LogOpts `json:"log-opts,omitempty"` // 日志配置选项 + CgroupDriver string `json:"cgroup-driver,omitempty"` // cgroup 驱动(从 exec-opts 中提取) + Hosts []string `json:"hosts,omitempty"` // Socket 路径 + DataRoot string `json:"data-root,omitempty"` // 数据目录 + StorageDriver string `json:"storage-driver,omitempty"` // 存储驱动 + DNS []string `json:"dns,omitempty"` // DNS 配置 + FirewallBackend string `json:"firewall-backend,omitempty"` // 防火墙后端 (iptables/nftables) + Iptables *bool `json:"iptables,omitempty"` // iptables 规则 + Ip6tables *bool `json:"ip6tables,omitempty"` // ip6tables 规则 + IpForward *bool `json:"ip-forward,omitempty"` // IP 转发 + IPv6 *bool `json:"ipv6,omitempty"` // IPv6 支持 + Bip string `json:"bip,omitempty"` // 默认 bridge 网络 IP 段 +} + +// UpdateSettings 更新设置请求 +type UpdateSettings struct { + Settings Settings `json:"settings" validate:"required"` +} diff --git a/internal/apps/docker/types.go b/internal/apps/docker/types.go new file mode 100644 index 00000000..3776a793 --- /dev/null +++ b/internal/apps/docker/types.go @@ -0,0 +1,23 @@ +package docker + +// DaemonConfig Docker daemon.json 完整配置结构 +type DaemonConfig struct { + RegistryMirrors []string `json:"registry-mirrors,omitempty"` + InsecureRegistries []string `json:"insecure-registries,omitempty"` + LiveRestore bool `json:"live-restore,omitempty"` + LogDriver string `json:"log-driver,omitempty"` + LogOpts map[string]string `json:"log-opts,omitempty"` + ExecOpts []string `json:"exec-opts,omitempty"` + Hosts []string `json:"hosts,omitempty"` + DataRoot string `json:"data-root,omitempty"` + StorageDriver string `json:"storage-driver,omitempty"` + DNS []string `json:"dns,omitempty"` + FirewallBackend string `json:"firewall-backend,omitempty"` + Iptables *bool `json:"iptables,omitempty"` + Ip6tables *bool `json:"ip6tables,omitempty"` + IpForward *bool `json:"ip-forward,omitempty"` + IPv6 *bool `json:"ipv6,omitempty"` + Bip string `json:"bip,omitempty"` + // 其他原有配置字段保留 + Extra map[string]any `json:"-"` +} diff --git a/web/src/api/apps/docker/index.ts b/web/src/api/apps/docker/index.ts index 75a6e4f6..6e2712f2 100644 --- a/web/src/api/apps/docker/index.ts +++ b/web/src/api/apps/docker/index.ts @@ -2,5 +2,8 @@ import { http } from '@/utils' export default { config: (): any => http.Get('/apps/docker/config'), - updateConfig: (config: string): any => http.Post('/apps/docker/config', { config }) + updateConfig: (config: string): any => http.Post('/apps/docker/config', { config }), + settings: (): any => http.Get('/apps/docker/settings'), + updateSettings: (settings: any): any => + http.Post('/apps/docker/settings', { settings }) } diff --git a/web/src/views/apps/docker/IndexView.vue b/web/src/views/apps/docker/IndexView.vue index 31abfb47..c57a9d19 100644 --- a/web/src/views/apps/docker/IndexView.vue +++ b/web/src/views/apps/docker/IndexView.vue @@ -3,7 +3,6 @@ defineOptions({ name: 'apps-docker-index' }) -import { NButton } from 'naive-ui' import { useGettext } from 'vue3-gettext' import docker from '@/api/apps/docker' @@ -18,11 +17,172 @@ const { data: config } = useRequest(docker.config, { } }) +// 基本设置 +const settingsLoading = ref(false) +const settings = ref({ + 'registry-mirrors': [], + 'insecure-registries': [], + 'live-restore': false, + 'log-driver': 'json-file', + 'log-opts': { + 'max-size': '', + 'max-file': '' + }, + 'cgroup-driver': '', + hosts: [], + 'data-root': '', + 'storage-driver': '', + dns: [], + 'firewall-backend': '', + 'ip-forward': true, + ipv6: false, + bip: '' +}) + +// 镜像输入 +const mirrorInput = ref('') +const insecureRegistryInput = ref('') +const dnsInput = ref('') +const hostInput = ref('') + +// 日志驱动选项 +const logDriverOptions = [ + { label: 'json-file', value: 'json-file' }, + { label: 'local', value: 'local' }, + { label: 'journald', value: 'journald' }, + { label: 'syslog', value: 'syslog' }, + { label: 'fluentd', value: 'fluentd' }, + { label: 'gelf', value: 'gelf' }, + { label: 'splunk', value: 'splunk' }, + { label: 'awslogs', value: 'awslogs' }, + { label: 'none', value: 'none' } +] + +// cgroup 驱动选项 +const cgroupDriverOptions = [ + { label: $gettext('Default'), value: '' }, + { label: 'systemd', value: 'systemd' }, + { label: 'cgroupfs', value: 'cgroupfs' } +] + +// 存储驱动选项 +const storageDriverOptions = [ + { label: $gettext('Default'), value: '' }, + { label: 'overlay2', value: 'overlay2' }, + { label: 'fuse-overlayfs', value: 'fuse-overlayfs' }, + { label: 'btrfs', value: 'btrfs' }, + { label: 'zfs', value: 'zfs' }, + { label: 'vfs', value: 'vfs' } +] + +// 防火墙后端选项 +const firewallBackendOptions = [ + { label: 'iptables (' + $gettext('Default') + ')', value: '' }, + { label: 'iptables', value: 'iptables' }, + { label: 'nftables (' + $gettext('Experimental') + ')', value: 'nftables' }, + { label: $gettext('None'), value: 'none' } +] + +// 常用镜像源预设 +const mirrorPresets = [ + { label: $gettext('China - Millisecond'), value: 'https://docker.1ms.run' }, + { label: $gettext('China - DaoCloud'), value: 'https://docker.m.daocloud.io' }, + { + label: $gettext('China - Tencent (Internal only)'), + value: 'https://mirror.ccs.tencentyun.com' + } +] + +// 获取设置 +const fetchSettings = () => { + settingsLoading.value = true + useRequest(docker.settings()) + .onSuccess((res) => { + settings.value = { + 'registry-mirrors': res.data['registry-mirrors'] || [], + 'insecure-registries': res.data['insecure-registries'] || [], + 'live-restore': res.data['live-restore'] || false, + 'log-driver': res.data['log-driver'] || 'json-file', + 'log-opts': { + 'max-size': res.data['log-opts']?.['max-size'] || '', + 'max-file': res.data['log-opts']?.['max-file'] || '' + }, + 'cgroup-driver': res.data['cgroup-driver'] || '', + hosts: res.data.hosts || [], + 'data-root': res.data['data-root'] || '', + 'storage-driver': res.data['storage-driver'] || '', + dns: res.data.dns || [], + 'firewall-backend': res.data['firewall-backend'] || '', + 'ip-forward': res.data['ip-forward'] ?? true, + ipv6: res.data.ipv6 ?? false, + bip: res.data.bip || '' + } + }) + .onComplete(() => { + settingsLoading.value = false + }) +} + +// 添加镜像 +const addMirror = () => { + if (mirrorInput.value && !settings.value['registry-mirrors']?.includes(mirrorInput.value)) { + settings.value['registry-mirrors'] = [ + ...(settings.value['registry-mirrors'] || []), + mirrorInput.value + ] + mirrorInput.value = '' + } +} + +// 添加非安全镜像仓库 +const addInsecureRegistry = () => { + if ( + insecureRegistryInput.value && + !settings.value['insecure-registries']?.includes(insecureRegistryInput.value) + ) { + settings.value['insecure-registries'] = [ + ...(settings.value['insecure-registries'] || []), + insecureRegistryInput.value + ] + insecureRegistryInput.value = '' + } +} + +// 添加 DNS +const addDns = () => { + if (dnsInput.value && !settings.value.dns?.includes(dnsInput.value)) { + settings.value.dns = [...(settings.value.dns || []), dnsInput.value] + dnsInput.value = '' + } +} + +// 添加 Host +const addHost = () => { + if (hostInput.value && !settings.value.hosts?.includes(hostInput.value)) { + settings.value.hosts = [...(settings.value.hosts || []), hostInput.value] + hostInput.value = '' + } +} + +// 保存设置 +const handleSaveSettings = () => { + useRequest(docker.updateSettings(settings.value)).onSuccess(() => { + window.$message.success($gettext('Saved successfully')) + }) +} + const handleSaveConfig = () => { useRequest(docker.updateConfig(config.value)).onSuccess(() => { window.$message.success($gettext('Saved successfully')) }) } + +// 监听 tab 切换,加载设置 +watch(currentTab, (tab) => { + if (tab === 'settings') { + fetchSettings() + } +})