mirror of
https://github.com/dqzboy/Docker-Proxy.git
synced 2026-03-18 01:43:03 +01:00
feat: 增加多个Registry搜索源支持
This commit is contained in:
@@ -105,6 +105,22 @@ class Database {
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Registry 配置表 - 用于存储各 Registry 平台的启用状态和代理地址
|
||||
`CREATE TABLE IF NOT EXISTS registry_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
registry_id TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
prefix TEXT,
|
||||
description TEXT,
|
||||
proxy_url TEXT,
|
||||
enabled BOOLEAN DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
];
|
||||
|
||||
|
||||
@@ -122,4 +122,67 @@ router.post('/menu-items', requireLogin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有 Registry 配置
|
||||
router.get('/registry-configs', async (req, res) => {
|
||||
try {
|
||||
const configs = await configServiceDB.getRegistryConfigs();
|
||||
res.json(configs);
|
||||
} catch (error) {
|
||||
logger.error('获取 Registry 配置失败:', error);
|
||||
res.status(500).json({ error: '获取 Registry 配置失败', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取启用的 Registry 配置
|
||||
router.get('/registry-configs/enabled', async (req, res) => {
|
||||
try {
|
||||
const configs = await configServiceDB.getEnabledRegistryConfigs();
|
||||
res.json(configs);
|
||||
} catch (error) {
|
||||
logger.error('获取启用的 Registry 配置失败:', error);
|
||||
res.status(500).json({ error: '获取启用的 Registry 配置失败', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新单个 Registry 配置
|
||||
router.put('/registry-configs/:registryId', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const { registryId } = req.params;
|
||||
const config = req.body;
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return res.status(400).json({
|
||||
error: '无效的配置数据',
|
||||
details: '配置必须是一个对象'
|
||||
});
|
||||
}
|
||||
|
||||
await configServiceDB.updateRegistryConfig(registryId, config);
|
||||
res.json({ success: true, message: `Registry ${registryId} 配置已更新` });
|
||||
} catch (error) {
|
||||
logger.error('更新 Registry 配置失败:', error);
|
||||
res.status(500).json({ error: '更新 Registry 配置失败', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 批量更新 Registry 配置
|
||||
router.post('/registry-configs', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const { configs } = req.body;
|
||||
|
||||
if (!Array.isArray(configs)) {
|
||||
return res.status(400).json({
|
||||
error: '无效的配置数据',
|
||||
details: '配置必须是一个数组'
|
||||
});
|
||||
}
|
||||
|
||||
await configServiceDB.updateRegistryConfigs(configs);
|
||||
res.json({ success: true, message: 'Registry 配置已保存' });
|
||||
} catch (error) {
|
||||
logger.error('保存 Registry 配置失败:', error);
|
||||
res.status(500).json({ error: '保存 Registry 配置失败', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
204
hubcmdui/routes/registry.js
Normal file
204
hubcmdui/routes/registry.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 多 Registry 搜索路由
|
||||
* 支持 Docker Hub、GHCR、Quay、GCR、K8s、MCR、Elastic 等平台
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logger = require('../logger');
|
||||
const registrySearchService = require('../services/registrySearchService');
|
||||
|
||||
/**
|
||||
* 获取支持的 Registry 列表
|
||||
* GET /api/registry/list
|
||||
*/
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const registries = registrySearchService.getRegistryList();
|
||||
res.json({
|
||||
success: true,
|
||||
registries
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('获取 Registry 列表失败:', err.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取 Registry 列表失败',
|
||||
details: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 搜索指定 Registry
|
||||
* GET /api/registry/search/:registryId
|
||||
* 参数:
|
||||
* - registryId: Registry 标识 (docker-hub, ghcr, quay, gcr, k8s, mcr, elastic, nvcr)
|
||||
* - term: 搜索关键词
|
||||
* - page: 页码(默认 1)
|
||||
* - limit: 每页数量(默认 25)
|
||||
*/
|
||||
router.get('/search/:registryId', async (req, res) => {
|
||||
try {
|
||||
const { registryId } = req.params;
|
||||
const { term, page = 1, limit = 25 } = req.query;
|
||||
|
||||
if (!term) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少搜索关键字(term)'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`搜索 ${registryId}: 关键字="${term}", 页码=${page}`);
|
||||
|
||||
const result = await registrySearchService.searchRegistry(
|
||||
registryId,
|
||||
term,
|
||||
parseInt(page),
|
||||
parseInt(limit)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Registry 搜索失败 (${req.params.registryId}):`, err.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Registry 搜索失败',
|
||||
details: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 搜索所有 Registry(聚合搜索)
|
||||
* GET /api/registry/search-all
|
||||
* 参数:
|
||||
* - term: 搜索关键词
|
||||
* - page: 页码(默认 1)
|
||||
* - limit: 每个 Registry 返回的数量(默认 10)
|
||||
*/
|
||||
router.get('/search-all', async (req, res) => {
|
||||
try {
|
||||
const { term, page = 1, limit = 10 } = req.query;
|
||||
|
||||
if (!term) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少搜索关键字(term)'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`聚合搜索所有 Registry: 关键字="${term}", 页码=${page}`);
|
||||
|
||||
const result = await registrySearchService.searchAllRegistries(
|
||||
term,
|
||||
parseInt(page),
|
||||
parseInt(limit)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('聚合搜索失败:', err.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '聚合搜索失败',
|
||||
details: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取镜像标签
|
||||
* GET /api/registry/tags/:registryId
|
||||
* 参数:
|
||||
* - registryId: Registry 标识
|
||||
* - name: 镜像名称
|
||||
* - page: 页码(默认 1)
|
||||
* - limit: 每页数量(默认 100)
|
||||
*/
|
||||
router.get('/tags/:registryId', async (req, res) => {
|
||||
try {
|
||||
const { registryId } = req.params;
|
||||
const { name, page = 1, limit = 100 } = req.query;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少镜像名称(name)'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`获取 ${registryId} 镜像标签: ${name}, 页码=${page}`);
|
||||
|
||||
const result = await registrySearchService.getImageTags(
|
||||
registryId,
|
||||
name,
|
||||
parseInt(page),
|
||||
parseInt(limit)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`获取镜像标签失败 (${req.params.registryId}):`, err.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取镜像标签失败',
|
||||
details: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取标签数量
|
||||
* GET /api/registry/tag-count/:registryId
|
||||
* 参数:
|
||||
* - registryId: Registry 标识
|
||||
* - name: 镜像名称
|
||||
*/
|
||||
router.get('/tag-count/:registryId', async (req, res) => {
|
||||
try {
|
||||
const { registryId } = req.params;
|
||||
const { name } = req.query;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少镜像名称(name)'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`获取 ${registryId} 镜像标签数量: ${name}`);
|
||||
|
||||
// 获取第一页标签来获取总数
|
||||
const result = await registrySearchService.getImageTags(
|
||||
registryId,
|
||||
name,
|
||||
1,
|
||||
1
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: result.count || 0,
|
||||
recommended_mode: result.count > 500 ? 'paginated' : 'full'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`获取标签数量失败 (${req.params.registryId}):`, err.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取标签数量失败',
|
||||
details: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -138,6 +138,9 @@ async function initializeSQLite() {
|
||||
const configServiceDB = require('../services/configServiceDB');
|
||||
await configServiceDB.initializeDefaultConfig();
|
||||
|
||||
// 初始化 Registry 配置
|
||||
await configServiceDB.initializeRegistryConfigs();
|
||||
|
||||
// 标记数据库已初始化
|
||||
await database.markAsInitialized();
|
||||
|
||||
|
||||
@@ -13,16 +13,18 @@ async function initializeDatabase() {
|
||||
// 连接数据库
|
||||
await database.connect();
|
||||
|
||||
// 始终运行 createTables 以确保新表被创建 (使用 IF NOT EXISTS 是安全的)
|
||||
await database.createTables();
|
||||
|
||||
// 检查数据库是否已经初始化
|
||||
const isInitialized = await database.isInitialized();
|
||||
if (isInitialized) {
|
||||
logger.info('数据库已经初始化,跳过重复初始化');
|
||||
logger.info('数据库已经初始化,检查并初始化新配置...');
|
||||
// 即使已初始化,也要确保 Registry 配置存在
|
||||
await configServiceDB.initializeRegistryConfigs();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建数据表
|
||||
await database.createTables();
|
||||
|
||||
// 创建默认管理员用户(如果不存在)
|
||||
await database.createDefaultAdmin();
|
||||
|
||||
@@ -32,6 +34,9 @@ async function initializeDatabase() {
|
||||
// 初始化默认配置
|
||||
await configServiceDB.initializeDefaultConfig();
|
||||
|
||||
// 初始化 Registry 配置
|
||||
await configServiceDB.initializeRegistryConfigs();
|
||||
|
||||
// 标记数据库已初始化
|
||||
await database.markAsInitialized();
|
||||
|
||||
|
||||
@@ -228,6 +228,287 @@ class ConfigServiceDB {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认 Registry 配置
|
||||
*/
|
||||
getDefaultRegistryConfigs() {
|
||||
return [
|
||||
{
|
||||
registry_id: 'docker-hub',
|
||||
name: 'Docker Hub',
|
||||
icon: 'fab fa-docker',
|
||||
color: '#2496ED',
|
||||
prefix: '',
|
||||
description: 'Docker 官方镜像仓库',
|
||||
proxy_url: 'registry-1.docker.io',
|
||||
enabled: true,
|
||||
sort_order: 1
|
||||
},
|
||||
{
|
||||
registry_id: 'ghcr',
|
||||
name: 'GitHub Container Registry',
|
||||
icon: 'fab fa-github',
|
||||
color: '#333333',
|
||||
prefix: 'ghcr.io',
|
||||
description: 'GitHub 容器镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 2
|
||||
},
|
||||
{
|
||||
registry_id: 'quay',
|
||||
name: 'Quay.io',
|
||||
icon: 'fas fa-cube',
|
||||
color: '#40B4E5',
|
||||
prefix: 'quay.io',
|
||||
description: 'Red Hat Quay 容器镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 3
|
||||
},
|
||||
{
|
||||
registry_id: 'gcr',
|
||||
name: 'Google Container Registry',
|
||||
icon: 'fab fa-google',
|
||||
color: '#4285F4',
|
||||
prefix: 'gcr.io',
|
||||
description: 'Google 容器镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 4
|
||||
},
|
||||
{
|
||||
registry_id: 'k8s',
|
||||
name: 'Kubernetes Registry',
|
||||
icon: 'fas fa-dharmachakra',
|
||||
color: '#326CE5',
|
||||
prefix: 'registry.k8s.io',
|
||||
description: 'Kubernetes 官方镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 5
|
||||
},
|
||||
{
|
||||
registry_id: 'mcr',
|
||||
name: 'Microsoft Container Registry',
|
||||
icon: 'fab fa-microsoft',
|
||||
color: '#00A4EF',
|
||||
prefix: 'mcr.microsoft.com',
|
||||
description: 'Microsoft 容器镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 6
|
||||
},
|
||||
{
|
||||
registry_id: 'elastic',
|
||||
name: 'Elastic Container Registry',
|
||||
icon: 'fas fa-bolt',
|
||||
color: '#FEC514',
|
||||
prefix: 'docker.elastic.co',
|
||||
description: 'Elastic 官方镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 7
|
||||
},
|
||||
{
|
||||
registry_id: 'nvcr',
|
||||
name: 'NVIDIA Container Registry',
|
||||
icon: 'fas fa-microchip',
|
||||
color: '#76B900',
|
||||
prefix: 'nvcr.io',
|
||||
description: 'NVIDIA GPU 容器镜像仓库',
|
||||
proxy_url: '',
|
||||
enabled: false,
|
||||
sort_order: 8
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Registry 配置
|
||||
*/
|
||||
async initializeRegistryConfigs() {
|
||||
try {
|
||||
const defaultConfigs = this.getDefaultRegistryConfigs();
|
||||
|
||||
for (const config of defaultConfigs) {
|
||||
const existing = await database.get(
|
||||
'SELECT id FROM registry_configs WHERE registry_id = ?',
|
||||
[config.registry_id]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
await database.run(
|
||||
`INSERT INTO registry_configs
|
||||
(registry_id, name, icon, color, prefix, description, proxy_url, enabled, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
config.registry_id,
|
||||
config.name,
|
||||
config.icon,
|
||||
config.color,
|
||||
config.prefix,
|
||||
config.description,
|
||||
config.proxy_url,
|
||||
config.enabled ? 1 : 0,
|
||||
config.sort_order,
|
||||
new Date().toISOString(),
|
||||
new Date().toISOString()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Registry 配置初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('初始化 Registry 配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Registry 配置
|
||||
*/
|
||||
async getRegistryConfigs() {
|
||||
try {
|
||||
const configs = await database.all(
|
||||
'SELECT * FROM registry_configs ORDER BY sort_order'
|
||||
);
|
||||
|
||||
// 如果表不存在或为空,返回默认配置
|
||||
if (!configs || configs.length === 0) {
|
||||
return this.getDefaultRegistryConfigs().map(config => ({
|
||||
registryId: config.registry_id,
|
||||
name: config.name,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
prefix: config.prefix,
|
||||
description: config.description,
|
||||
proxyUrl: config.proxy_url,
|
||||
enabled: config.enabled,
|
||||
sortOrder: config.sort_order
|
||||
}));
|
||||
}
|
||||
|
||||
return configs.map(config => ({
|
||||
registryId: config.registry_id,
|
||||
name: config.name,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
prefix: config.prefix,
|
||||
description: config.description,
|
||||
proxyUrl: config.proxy_url,
|
||||
enabled: Boolean(config.enabled),
|
||||
sortOrder: config.sort_order
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('获取 Registry 配置失败:', error);
|
||||
// 返回默认配置
|
||||
return this.getDefaultRegistryConfigs().map(config => ({
|
||||
registryId: config.registry_id,
|
||||
name: config.name,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
prefix: config.prefix,
|
||||
description: config.description,
|
||||
proxyUrl: config.proxy_url,
|
||||
enabled: config.enabled,
|
||||
sortOrder: config.sort_order
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的 Registry 配置
|
||||
*/
|
||||
async getEnabledRegistryConfigs() {
|
||||
try {
|
||||
const configs = await database.all(
|
||||
'SELECT * FROM registry_configs WHERE enabled = 1 ORDER BY sort_order'
|
||||
);
|
||||
|
||||
// 如果没有启用的配置,返回默认的 Docker Hub
|
||||
if (!configs || configs.length === 0) {
|
||||
return [{
|
||||
registryId: 'docker-hub',
|
||||
name: 'Docker Hub',
|
||||
icon: 'fab fa-docker',
|
||||
color: '#2496ED',
|
||||
prefix: '',
|
||||
description: 'Docker 官方镜像仓库',
|
||||
proxyUrl: '',
|
||||
enabled: true,
|
||||
sortOrder: 1
|
||||
}];
|
||||
}
|
||||
|
||||
return configs.map(config => ({
|
||||
registryId: config.registry_id,
|
||||
name: config.name,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
prefix: config.prefix,
|
||||
description: config.description,
|
||||
proxyUrl: config.proxy_url,
|
||||
enabled: true,
|
||||
sortOrder: config.sort_order
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('获取启用的 Registry 配置失败:', error);
|
||||
// 返回默认的 Docker Hub 配置
|
||||
return [{
|
||||
registryId: 'docker-hub',
|
||||
name: 'Docker Hub',
|
||||
icon: 'fab fa-docker',
|
||||
color: '#2496ED',
|
||||
prefix: '',
|
||||
description: 'Docker 官方镜像仓库',
|
||||
proxyUrl: '',
|
||||
enabled: true,
|
||||
sortOrder: 1
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个 Registry 配置
|
||||
*/
|
||||
async updateRegistryConfig(registryId, config) {
|
||||
try {
|
||||
await database.run(
|
||||
`UPDATE registry_configs
|
||||
SET proxy_url = ?, enabled = ?, updated_at = ?
|
||||
WHERE registry_id = ?`,
|
||||
[
|
||||
config.proxyUrl || '',
|
||||
config.enabled ? 1 : 0,
|
||||
new Date().toISOString(),
|
||||
registryId
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`Registry 配置 ${registryId} 更新成功`);
|
||||
} catch (error) {
|
||||
logger.error('更新 Registry 配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新 Registry 配置
|
||||
*/
|
||||
async updateRegistryConfigs(configs) {
|
||||
try {
|
||||
for (const config of configs) {
|
||||
await this.updateRegistryConfig(config.registryId, config);
|
||||
}
|
||||
logger.info('批量更新 Registry 配置成功');
|
||||
} catch (error) {
|
||||
logger.error('批量更新 Registry 配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ConfigServiceDB();
|
||||
|
||||
648
hubcmdui/services/registrySearchService.js
Normal file
648
hubcmdui/services/registrySearchService.js
Normal file
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* 多 Registry 搜索服务模块
|
||||
* 支持 ghcr.io、k8s.gcr.io、quay.io、gcr.io、Elastic、mcr 等公共 Registry 平台
|
||||
*/
|
||||
const axios = require('axios');
|
||||
const logger = require('../logger');
|
||||
|
||||
// HTTP 请求配置
|
||||
const httpOptions = {
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'RegistrySearchClient/1.0',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
// Registry 平台配置
|
||||
const REGISTRY_CONFIGS = {
|
||||
'docker-hub': {
|
||||
name: 'Docker Hub',
|
||||
icon: 'fab fa-docker',
|
||||
color: '#2496ED',
|
||||
searchUrl: 'https://hub.docker.com/v2/search/repositories/',
|
||||
tagsUrl: 'https://hub.docker.com/v2/repositories/{namespace}/{repo}/tags',
|
||||
prefix: '',
|
||||
description: 'Docker 官方镜像仓库'
|
||||
},
|
||||
'ghcr': {
|
||||
name: 'GitHub Container Registry',
|
||||
icon: 'fab fa-github',
|
||||
color: '#333',
|
||||
// GHCR 没有直接的搜索API,使用 GitHub API 搜索包含 container 的仓库
|
||||
searchUrl: 'https://api.github.com/search/repositories',
|
||||
tagsUrl: 'https://ghcr.io/v2/{namespace}/{repo}/tags/list',
|
||||
prefix: 'ghcr.io',
|
||||
description: 'GitHub 容器镜像仓库'
|
||||
},
|
||||
'quay': {
|
||||
name: 'Quay.io',
|
||||
icon: 'fas fa-cube',
|
||||
color: '#40B4E5',
|
||||
searchUrl: 'https://quay.io/api/v1/find/repositories',
|
||||
tagsUrl: 'https://quay.io/api/v1/repository/{namespace}/{repo}/tag/',
|
||||
prefix: 'quay.io',
|
||||
description: 'Red Hat Quay 容器镜像仓库'
|
||||
},
|
||||
'gcr': {
|
||||
name: 'Google Container Registry',
|
||||
icon: 'fab fa-google',
|
||||
color: '#4285F4',
|
||||
// GCR 没有公开的搜索 API,使用静态列表
|
||||
catalogUrl: 'https://gcr.io/v2/_catalog',
|
||||
tagsUrl: 'https://gcr.io/v2/{namespace}/{repo}/tags/list',
|
||||
prefix: 'gcr.io',
|
||||
description: 'Google 容器镜像仓库'
|
||||
},
|
||||
'k8s': {
|
||||
name: 'Kubernetes Registry',
|
||||
icon: 'fas fa-dharmachakra',
|
||||
color: '#326CE5',
|
||||
// K8s 镜像现在在 registry.k8s.io
|
||||
catalogUrl: 'https://registry.k8s.io/v2/_catalog',
|
||||
tagsUrl: 'https://registry.k8s.io/v2/{repo}/tags/list',
|
||||
prefix: 'registry.k8s.io',
|
||||
description: 'Kubernetes 官方镜像仓库'
|
||||
},
|
||||
'mcr': {
|
||||
name: 'Microsoft Container Registry',
|
||||
icon: 'fab fa-microsoft',
|
||||
color: '#00A4EF',
|
||||
// MCR 使用 Docker Hub 风格的 API
|
||||
catalogUrl: 'https://mcr.microsoft.com/v2/_catalog',
|
||||
tagsUrl: 'https://mcr.microsoft.com/v2/{repo}/tags/list',
|
||||
prefix: 'mcr.microsoft.com',
|
||||
description: 'Microsoft 容器镜像仓库'
|
||||
},
|
||||
'elastic': {
|
||||
name: 'Elastic Container Registry',
|
||||
icon: 'fas fa-bolt',
|
||||
color: '#FEC514',
|
||||
// Elastic 镜像托管在 docker.elastic.co
|
||||
catalogUrl: 'https://docker.elastic.co/v2/_catalog',
|
||||
tagsUrl: 'https://docker.elastic.co/v2/{repo}/tags/list',
|
||||
prefix: 'docker.elastic.co',
|
||||
description: 'Elastic 官方镜像仓库'
|
||||
},
|
||||
'nvcr': {
|
||||
name: 'NVIDIA Container Registry',
|
||||
icon: 'fas fa-microchip',
|
||||
color: '#76B900',
|
||||
prefix: 'nvcr.io',
|
||||
description: 'NVIDIA GPU 容器镜像仓库'
|
||||
}
|
||||
};
|
||||
|
||||
// 常用镜像的静态列表(用于不支持搜索 API 的 Registry)
|
||||
const STATIC_IMAGE_LISTS = {
|
||||
'k8s': [
|
||||
{ name: 'kube-apiserver', description: 'Kubernetes API Server' },
|
||||
{ name: 'kube-controller-manager', description: 'Kubernetes Controller Manager' },
|
||||
{ name: 'kube-scheduler', description: 'Kubernetes Scheduler' },
|
||||
{ name: 'kube-proxy', description: 'Kubernetes Proxy' },
|
||||
{ name: 'etcd', description: 'Etcd 分布式键值存储' },
|
||||
{ name: 'coredns', description: 'CoreDNS - Kubernetes DNS 服务' },
|
||||
{ name: 'pause', description: 'Kubernetes Pause 容器' },
|
||||
{ name: 'ingress-nginx/controller', description: 'NGINX Ingress Controller' },
|
||||
{ name: 'metrics-server', description: 'Kubernetes Metrics Server' },
|
||||
{ name: 'dashboard', description: 'Kubernetes Dashboard' },
|
||||
{ name: 'dns/k8s-dns-node-cache', description: 'NodeLocal DNSCache' },
|
||||
{ name: 'sig-storage/csi-provisioner', description: 'CSI Provisioner' },
|
||||
{ name: 'sig-storage/csi-attacher', description: 'CSI Attacher' },
|
||||
{ name: 'sig-storage/csi-snapshotter', description: 'CSI Snapshotter' },
|
||||
{ name: 'sig-storage/csi-resizer', description: 'CSI Resizer' },
|
||||
{ name: 'sig-storage/csi-node-driver-registrar', description: 'CSI Node Driver Registrar' },
|
||||
{ name: 'autoscaling/vpa-recommender', description: 'VPA Recommender' },
|
||||
{ name: 'autoscaling/vpa-updater', description: 'VPA Updater' },
|
||||
{ name: 'autoscaling/vpa-admission-controller', description: 'VPA Admission Controller' }
|
||||
],
|
||||
'gcr': [
|
||||
{ name: 'google-containers/pause', description: 'Google Pause 容器' },
|
||||
{ name: 'google-containers/busybox', description: 'BusyBox 镜像' },
|
||||
{ name: 'google-containers/kube-state-metrics', description: 'Kube State Metrics' },
|
||||
{ name: 'google-containers/prometheus-to-sd', description: 'Prometheus to Stackdriver' },
|
||||
{ name: 'google-containers/fluentd-gcp', description: 'Fluentd for GCP' },
|
||||
{ name: 'google-containers/addon-resizer', description: 'Addon Resizer' },
|
||||
{ name: 'google-containers/cluster-proportional-autoscaler-amd64', description: 'Cluster Proportional Autoscaler' },
|
||||
{ name: 'distroless/base', description: 'Google Distroless Base 镜像' },
|
||||
{ name: 'distroless/static', description: 'Google Distroless Static 镜像' },
|
||||
{ name: 'distroless/java', description: 'Google Distroless Java 镜像' },
|
||||
{ name: 'distroless/cc', description: 'Google Distroless CC 镜像' },
|
||||
{ name: 'distroless/python3', description: 'Google Distroless Python3 镜像' },
|
||||
{ name: 'distroless/nodejs', description: 'Google Distroless Node.js 镜像' },
|
||||
{ name: 'cadvisor/cadvisor', description: 'Container Advisor' }
|
||||
],
|
||||
'mcr': [
|
||||
{ name: 'dotnet/aspnet', description: 'ASP.NET Core 运行时镜像' },
|
||||
{ name: 'dotnet/runtime', description: '.NET 运行时镜像' },
|
||||
{ name: 'dotnet/sdk', description: '.NET SDK 镜像' },
|
||||
{ name: 'dotnet/runtime-deps', description: '.NET 运行时依赖镜像' },
|
||||
{ name: 'mssql/server', description: 'Microsoft SQL Server 镜像' },
|
||||
{ name: 'azure-cli', description: 'Azure CLI 镜像' },
|
||||
{ name: 'powershell', description: 'PowerShell 镜像' },
|
||||
{ name: 'windows/servercore', description: 'Windows Server Core 镜像' },
|
||||
{ name: 'windows/nanoserver', description: 'Windows Nano Server 镜像' },
|
||||
{ name: 'windows', description: 'Windows 基础镜像' },
|
||||
{ name: 'oss/kubernetes/pause', description: 'Kubernetes Pause 镜像 (MCR)' },
|
||||
{ name: 'oss/azure/aad-pod-identity/nmi', description: 'Azure AAD Pod Identity NMI' },
|
||||
{ name: 'azure-cognitive-services/textanalytics/healthcare', description: 'Text Analytics for Health' },
|
||||
{ name: 'playwright', description: 'Playwright 浏览器自动化镜像' },
|
||||
{ name: 'vscode/devcontainers/base', description: 'VS Code Dev Containers 基础镜像' },
|
||||
{ name: 'devcontainers/base', description: 'Dev Containers 基础镜像' },
|
||||
{ name: 'devcontainers/python', description: 'Dev Containers Python 镜像' },
|
||||
{ name: 'devcontainers/typescript-node', description: 'Dev Containers TypeScript Node 镜像' },
|
||||
{ name: 'devcontainers/go', description: 'Dev Containers Go 镜像' },
|
||||
{ name: 'devcontainers/java', description: 'Dev Containers Java 镜像' }
|
||||
],
|
||||
'elastic': [
|
||||
{ name: 'elasticsearch/elasticsearch', description: 'Elasticsearch 分布式搜索引擎' },
|
||||
{ name: 'kibana/kibana', description: 'Kibana 数据可视化平台' },
|
||||
{ name: 'logstash/logstash', description: 'Logstash 数据处理管道' },
|
||||
{ name: 'beats/filebeat', description: 'Filebeat 日志采集器' },
|
||||
{ name: 'beats/metricbeat', description: 'Metricbeat 指标采集器' },
|
||||
{ name: 'beats/heartbeat', description: 'Heartbeat 可用性监控' },
|
||||
{ name: 'beats/auditbeat', description: 'Auditbeat 审计数据采集' },
|
||||
{ name: 'beats/packetbeat', description: 'Packetbeat 网络数据采集' },
|
||||
{ name: 'apm/apm-server', description: 'APM Server 应用性能监控' },
|
||||
{ name: 'enterprise-search/enterprise-search', description: 'Elastic Enterprise Search' },
|
||||
{ name: 'observability/synthetics-runner', description: 'Synthetics Runner' },
|
||||
{ name: 'eck/eck-operator', description: 'Elastic Cloud on Kubernetes Operator' }
|
||||
],
|
||||
'ghcr': [
|
||||
// GHCR 使用 GitHub API 动态搜索,但这里列出一些常用镜像
|
||||
{ name: 'actions/runner', namespace: 'actions', description: 'GitHub Actions Runner' },
|
||||
{ name: 'dependabot/dependabot-core', namespace: 'dependabot', description: 'Dependabot Core' },
|
||||
{ name: 'aquasecurity/trivy', namespace: 'aquasecurity', description: 'Trivy 容器安全扫描' },
|
||||
{ name: 'fluxcd/flux2', namespace: 'fluxcd', description: 'Flux GitOps 工具' },
|
||||
{ name: 'fluxcd/helm-controller', namespace: 'fluxcd', description: 'Flux Helm Controller' },
|
||||
{ name: 'fluxcd/kustomize-controller', namespace: 'fluxcd', description: 'Flux Kustomize Controller' },
|
||||
{ name: 'fluxcd/source-controller', namespace: 'fluxcd', description: 'Flux Source Controller' },
|
||||
{ name: 'external-secrets/external-secrets', namespace: 'external-secrets', description: 'External Secrets Operator' },
|
||||
{ name: 'cert-manager/cert-manager-controller', namespace: 'cert-manager', description: 'Cert Manager Controller' },
|
||||
{ name: 'argoproj/argocd', namespace: 'argoproj', description: 'Argo CD GitOps' },
|
||||
{ name: 'bitnami/kubectl', namespace: 'bitnami', description: 'Bitnami kubectl' },
|
||||
{ name: 'bitnami/nginx', namespace: 'bitnami', description: 'Bitnami NGINX' }
|
||||
],
|
||||
'quay': [
|
||||
{ name: 'coreos/etcd', namespace: 'coreos', description: 'Etcd 分布式键值存储' },
|
||||
{ name: 'coreos/flannel', namespace: 'coreos', description: 'Flannel 网络插件' },
|
||||
{ name: 'coreos/prometheus-operator', namespace: 'coreos', description: 'Prometheus Operator' },
|
||||
{ name: 'prometheus/prometheus', namespace: 'prometheus', description: 'Prometheus 监控系统' },
|
||||
{ name: 'prometheus/alertmanager', namespace: 'prometheus', description: 'Alertmanager 告警管理' },
|
||||
{ name: 'prometheus/node-exporter', namespace: 'prometheus', description: 'Node Exporter' },
|
||||
{ name: 'prometheus/blackbox-exporter', namespace: 'prometheus', description: 'Blackbox Exporter' },
|
||||
{ name: 'jetstack/cert-manager-controller', namespace: 'jetstack', description: 'Cert Manager Controller' },
|
||||
{ name: 'jetstack/cert-manager-webhook', namespace: 'jetstack', description: 'Cert Manager Webhook' },
|
||||
{ name: 'jetstack/cert-manager-cainjector', namespace: 'jetstack', description: 'Cert Manager CA Injector' },
|
||||
{ name: 'metallb/controller', namespace: 'metallb', description: 'MetalLB Controller' },
|
||||
{ name: 'metallb/speaker', namespace: 'metallb', description: 'MetalLB Speaker' },
|
||||
{ name: 'calico/node', namespace: 'calico', description: 'Calico Node' },
|
||||
{ name: 'calico/cni', namespace: 'calico', description: 'Calico CNI' },
|
||||
{ name: 'calico/kube-controllers', namespace: 'calico', description: 'Calico Kube Controllers' },
|
||||
{ name: 'cilium/cilium', namespace: 'cilium', description: 'Cilium 网络插件' },
|
||||
{ name: 'cilium/operator', namespace: 'cilium', description: 'Cilium Operator' },
|
||||
{ name: 'argoproj/argocd', namespace: 'argoproj', description: 'Argo CD GitOps' },
|
||||
{ name: 'argoproj/argo-rollouts', namespace: 'argoproj', description: 'Argo Rollouts' },
|
||||
{ name: 'argoproj/argo-workflows', namespace: 'argoproj', description: 'Argo Workflows' }
|
||||
],
|
||||
'nvcr': [
|
||||
{ name: 'nvidia/cuda', description: 'NVIDIA CUDA 基础镜像' },
|
||||
{ name: 'nvidia/pytorch', description: 'NVIDIA PyTorch 容器' },
|
||||
{ name: 'nvidia/tensorflow', description: 'NVIDIA TensorFlow 容器' },
|
||||
{ name: 'nvidia/tensorrt', description: 'NVIDIA TensorRT 推理优化' },
|
||||
{ name: 'nvidia/tritonserver', description: 'NVIDIA Triton 推理服务器' },
|
||||
{ name: 'nvidia/cuda-quantum', description: 'NVIDIA CUDA Quantum' },
|
||||
{ name: 'nvidia/nemo', description: 'NVIDIA NeMo 对话式 AI' },
|
||||
{ name: 'nvidia/deepstream', description: 'NVIDIA DeepStream SDK' },
|
||||
{ name: 'nvidia/k8s-device-plugin', description: 'NVIDIA Kubernetes Device Plugin' },
|
||||
{ name: 'nvidia/gpu-operator', description: 'NVIDIA GPU Operator' },
|
||||
{ name: 'nvidia/dcgm-exporter', description: 'NVIDIA DCGM Exporter' },
|
||||
{ name: 'nvidia/driver', description: 'NVIDIA 驱动容器' }
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有支持的 Registry 平台列表
|
||||
*/
|
||||
function getRegistryList() {
|
||||
return Object.keys(REGISTRY_CONFIGS).map(key => ({
|
||||
id: key,
|
||||
...REGISTRY_CONFIGS[key]
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 Docker Hub
|
||||
*/
|
||||
async function searchDockerHub(term, page = 1, pageSize = 25) {
|
||||
const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${page}&page_size=${pageSize}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, httpOptions);
|
||||
const data = response.data;
|
||||
|
||||
return {
|
||||
registry: 'docker-hub',
|
||||
registryName: REGISTRY_CONFIGS['docker-hub'].name,
|
||||
registryIcon: REGISTRY_CONFIGS['docker-hub'].icon,
|
||||
registryColor: REGISTRY_CONFIGS['docker-hub'].color,
|
||||
count: data.count || 0,
|
||||
results: (data.results || []).map(item => ({
|
||||
name: item.name || item.repo_name,
|
||||
namespace: item.namespace || (item.is_official ? 'library' : item.name?.split('/')[0]),
|
||||
description: item.description || item.short_description || '',
|
||||
stars: item.star_count || 0,
|
||||
pulls: item.pull_count || 0,
|
||||
isOfficial: item.is_official || false,
|
||||
isAutomated: item.is_automated || false,
|
||||
fullName: item.is_official ? item.name : (item.repo_name || item.name),
|
||||
registry: 'docker-hub',
|
||||
pullCommand: item.is_official ? item.name : (item.repo_name || item.name)
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`搜索 Docker Hub 失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 Quay.io
|
||||
*/
|
||||
async function searchQuay(term, page = 1, pageSize = 25) {
|
||||
const url = `https://quay.io/api/v1/find/repositories?query=${encodeURIComponent(term)}&page=${page}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
...httpOptions,
|
||||
headers: {
|
||||
...httpOptions.headers,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const results = response.data.results || [];
|
||||
|
||||
return {
|
||||
registry: 'quay',
|
||||
registryName: REGISTRY_CONFIGS['quay'].name,
|
||||
registryIcon: REGISTRY_CONFIGS['quay'].icon,
|
||||
registryColor: REGISTRY_CONFIGS['quay'].color,
|
||||
count: results.length,
|
||||
results: results.map(item => ({
|
||||
name: item.name,
|
||||
namespace: item.namespace?.name || item.namespace,
|
||||
description: item.description || '',
|
||||
stars: item.popularity || 0,
|
||||
pulls: 0,
|
||||
isOfficial: item.is_public || false,
|
||||
isAutomated: false,
|
||||
fullName: `${item.namespace?.name || item.namespace}/${item.name}`,
|
||||
registry: 'quay',
|
||||
pullCommand: `quay.io/${item.namespace?.name || item.namespace}/${item.name}`
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`搜索 Quay.io 失败: ${error.message}`);
|
||||
// 如果 API 搜索失败,使用静态列表进行本地搜索
|
||||
return searchStaticList('quay', term);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 GitHub Container Registry (使用 GitHub API)
|
||||
*/
|
||||
async function searchGHCR(term, page = 1, pageSize = 25) {
|
||||
// 首先尝试使用静态列表搜索
|
||||
const staticResults = searchStaticList('ghcr', term);
|
||||
|
||||
try {
|
||||
// 然后尝试使用 GitHub API 搜索仓库
|
||||
const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(term)}+topic:docker+topic:container&per_page=${pageSize}&page=${page}`;
|
||||
|
||||
const response = await axios.get(url, {
|
||||
...httpOptions,
|
||||
headers: {
|
||||
...httpOptions.headers,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const apiResults = (data.items || []).map(item => ({
|
||||
name: item.name,
|
||||
namespace: item.owner?.login || '',
|
||||
description: item.description || '',
|
||||
stars: item.stargazers_count || 0,
|
||||
pulls: 0,
|
||||
isOfficial: item.owner?.type === 'Organization',
|
||||
isAutomated: false,
|
||||
fullName: `${item.owner?.login}/${item.name}`,
|
||||
registry: 'ghcr',
|
||||
pullCommand: `ghcr.io/${item.owner?.login}/${item.name}`,
|
||||
url: item.html_url
|
||||
}));
|
||||
|
||||
// 合并静态列表和 API 结果,去重
|
||||
const allResults = [...staticResults.results];
|
||||
const existingNames = new Set(allResults.map(r => r.fullName.toLowerCase()));
|
||||
|
||||
apiResults.forEach(item => {
|
||||
if (!existingNames.has(item.fullName.toLowerCase())) {
|
||||
allResults.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
registry: 'ghcr',
|
||||
registryName: REGISTRY_CONFIGS['ghcr'].name,
|
||||
registryIcon: REGISTRY_CONFIGS['ghcr'].icon,
|
||||
registryColor: REGISTRY_CONFIGS['ghcr'].color,
|
||||
count: allResults.length,
|
||||
results: allResults
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`GitHub API 搜索失败,使用静态列表: ${error.message}`);
|
||||
return staticResults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在静态列表中搜索
|
||||
*/
|
||||
function searchStaticList(registryId, term) {
|
||||
const config = REGISTRY_CONFIGS[registryId];
|
||||
const staticList = STATIC_IMAGE_LISTS[registryId] || [];
|
||||
const lowerTerm = term.toLowerCase();
|
||||
|
||||
const matchedResults = staticList.filter(item => {
|
||||
const nameMatch = item.name.toLowerCase().includes(lowerTerm);
|
||||
const descMatch = item.description && item.description.toLowerCase().includes(lowerTerm);
|
||||
return nameMatch || descMatch;
|
||||
});
|
||||
|
||||
return {
|
||||
registry: registryId,
|
||||
registryName: config.name,
|
||||
registryIcon: config.icon,
|
||||
registryColor: config.color,
|
||||
count: matchedResults.length,
|
||||
results: matchedResults.map(item => ({
|
||||
name: item.name.includes('/') ? item.name.split('/').pop() : item.name,
|
||||
namespace: item.namespace || (item.name.includes('/') ? item.name.split('/')[0] : ''),
|
||||
description: item.description || '',
|
||||
stars: 0,
|
||||
pulls: 0,
|
||||
isOfficial: true,
|
||||
isAutomated: false,
|
||||
fullName: item.name,
|
||||
registry: registryId,
|
||||
pullCommand: config.prefix ? `${config.prefix}/${item.name}` : item.name
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 Kubernetes Registry
|
||||
*/
|
||||
async function searchK8s(term, page = 1, pageSize = 25) {
|
||||
return searchStaticList('k8s', term);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 Google Container Registry
|
||||
*/
|
||||
async function searchGCR(term, page = 1, pageSize = 25) {
|
||||
return searchStaticList('gcr', term);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 Microsoft Container Registry
|
||||
*/
|
||||
async function searchMCR(term, page = 1, pageSize = 25) {
|
||||
return searchStaticList('mcr', term);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 Elastic Container Registry
|
||||
*/
|
||||
async function searchElastic(term, page = 1, pageSize = 25) {
|
||||
return searchStaticList('elastic', term);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 NVIDIA Container Registry
|
||||
*/
|
||||
async function searchNVCR(term, page = 1, pageSize = 25) {
|
||||
return searchStaticList('nvcr', term);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一搜索接口 - 搜索指定的 Registry
|
||||
*/
|
||||
async function searchRegistry(registryId, term, page = 1, pageSize = 25) {
|
||||
logger.info(`搜索 ${registryId}: ${term} (页码: ${page})`);
|
||||
|
||||
switch (registryId) {
|
||||
case 'docker-hub':
|
||||
return await searchDockerHub(term, page, pageSize);
|
||||
case 'quay':
|
||||
return await searchQuay(term, page, pageSize);
|
||||
case 'ghcr':
|
||||
return await searchGHCR(term, page, pageSize);
|
||||
case 'k8s':
|
||||
return await searchK8s(term, page, pageSize);
|
||||
case 'gcr':
|
||||
return await searchGCR(term, page, pageSize);
|
||||
case 'mcr':
|
||||
return await searchMCR(term, page, pageSize);
|
||||
case 'elastic':
|
||||
return await searchElastic(term, page, pageSize);
|
||||
case 'nvcr':
|
||||
return await searchNVCR(term, page, pageSize);
|
||||
default:
|
||||
throw new Error(`不支持的 Registry: ${registryId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索所有支持的 Registry
|
||||
*/
|
||||
async function searchAllRegistries(term, page = 1, pageSize = 10) {
|
||||
const registries = ['docker-hub', 'quay', 'ghcr', 'k8s', 'gcr', 'mcr', 'elastic', 'nvcr'];
|
||||
|
||||
const searchPromises = registries.map(registryId =>
|
||||
searchRegistry(registryId, term, page, pageSize)
|
||||
.catch(error => {
|
||||
logger.warn(`搜索 ${registryId} 失败: ${error.message}`);
|
||||
return {
|
||||
registry: registryId,
|
||||
registryName: REGISTRY_CONFIGS[registryId]?.name || registryId,
|
||||
count: 0,
|
||||
results: [],
|
||||
error: error.message
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(searchPromises);
|
||||
|
||||
return {
|
||||
term,
|
||||
page,
|
||||
pageSize,
|
||||
registries: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取镜像标签 - 根据 Registry 类型选择不同的 API
|
||||
*/
|
||||
async function getImageTags(registryId, imageName, page = 1, pageSize = 100) {
|
||||
const config = REGISTRY_CONFIGS[registryId];
|
||||
if (!config) {
|
||||
throw new Error(`不支持的 Registry: ${registryId}`);
|
||||
}
|
||||
|
||||
logger.info(`获取 ${registryId} 镜像标签: ${imageName}`);
|
||||
|
||||
switch (registryId) {
|
||||
case 'docker-hub':
|
||||
return await getDockerHubTags(imageName, page, pageSize);
|
||||
case 'quay':
|
||||
return await getQuayTags(imageName, page, pageSize);
|
||||
default:
|
||||
return await getOCITags(registryId, imageName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Docker Hub 镜像标签
|
||||
*/
|
||||
async function getDockerHubTags(imageName, page = 1, pageSize = 100) {
|
||||
const isOfficial = !imageName.includes('/');
|
||||
const fullImageName = isOfficial ? `library/${imageName}` : imageName;
|
||||
const url = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, httpOptions);
|
||||
const data = response.data;
|
||||
|
||||
return {
|
||||
registry: 'docker-hub',
|
||||
imageName,
|
||||
count: data.count || 0,
|
||||
results: (data.results || []).map(tag => ({
|
||||
name: tag.name,
|
||||
digest: tag.digest,
|
||||
lastUpdated: tag.last_updated,
|
||||
size: tag.full_size || tag.images?.[0]?.size,
|
||||
images: tag.images || []
|
||||
})),
|
||||
next: data.next,
|
||||
previous: data.previous
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`获取 Docker Hub 标签失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Quay.io 镜像标签
|
||||
*/
|
||||
async function getQuayTags(imageName, page = 1, pageSize = 100) {
|
||||
const [namespace, repo] = imageName.includes('/')
|
||||
? imageName.split('/')
|
||||
: ['library', imageName];
|
||||
|
||||
const url = `https://quay.io/api/v1/repository/${namespace}/${repo}/tag/?limit=${pageSize}&page=${page}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, httpOptions);
|
||||
const data = response.data;
|
||||
|
||||
return {
|
||||
registry: 'quay',
|
||||
imageName,
|
||||
count: data.tags?.length || 0,
|
||||
results: (data.tags || []).map(tag => ({
|
||||
name: tag.name,
|
||||
digest: tag.manifest_digest,
|
||||
lastUpdated: tag.last_modified,
|
||||
size: tag.size,
|
||||
images: []
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`获取 Quay 标签失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OCI Registry 镜像标签(适用于 GCR, MCR, K8s 等)
|
||||
*/
|
||||
async function getOCITags(registryId, imageName) {
|
||||
const config = REGISTRY_CONFIGS[registryId];
|
||||
if (!config || !config.tagsUrl) {
|
||||
throw new Error(`Registry ${registryId} 不支持获取标签`);
|
||||
}
|
||||
|
||||
// 构建 URL
|
||||
let url;
|
||||
if (imageName.includes('/')) {
|
||||
const [namespace, repo] = imageName.split('/');
|
||||
url = config.tagsUrl
|
||||
.replace('{namespace}', namespace)
|
||||
.replace('{repo}', repo);
|
||||
} else {
|
||||
url = config.tagsUrl.replace('{repo}', imageName);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
...httpOptions,
|
||||
headers: {
|
||||
...httpOptions.headers,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const tags = data.tags || [];
|
||||
|
||||
return {
|
||||
registry: registryId,
|
||||
imageName,
|
||||
count: tags.length,
|
||||
results: tags.map(tag => ({
|
||||
name: typeof tag === 'string' ? tag : tag.name,
|
||||
digest: typeof tag === 'object' ? tag.digest : null,
|
||||
lastUpdated: null,
|
||||
size: null,
|
||||
images: []
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`获取 ${registryId} 标签失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRegistryList,
|
||||
searchRegistry,
|
||||
searchAllRegistries,
|
||||
getImageTags,
|
||||
searchDockerHub,
|
||||
searchQuay,
|
||||
searchGHCR,
|
||||
searchK8s,
|
||||
searchGCR,
|
||||
searchMCR,
|
||||
searchElastic,
|
||||
searchNVCR,
|
||||
REGISTRY_CONFIGS,
|
||||
STATIC_IMAGE_LISTS
|
||||
};
|
||||
@@ -1802,7 +1802,7 @@
|
||||
}
|
||||
|
||||
/* 状态指示样式 */
|
||||
.disabled {
|
||||
.status-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -3340,6 +3340,253 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Registry 配置列表样式 */
|
||||
.registry-config-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.registry-config-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registry-config-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.registry-config-item {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.registry-config-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.registry-config-item.enabled {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.registry-config-item.disabled {
|
||||
border: 2px dashed rgba(148, 163, 184, 0.3);
|
||||
background: #fff;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.registry-config-item.disabled:hover {
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.registry-config-header {
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.registry-config-header:hover {
|
||||
background: linear-gradient(135deg, #f0f4f8 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.registry-config-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0,0,0,0.08), transparent);
|
||||
}
|
||||
|
||||
.registry-config-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.registry-config-info .registry-badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.registry-config-info .registry-badge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.registry-config-details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.registry-config-details h4 {
|
||||
margin: 0 0 0.15rem 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.registry-config-details .registry-prefix {
|
||||
display: inline-block;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.registry-config-details p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.registry-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.registry-status-indicator.enabled {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.registry-status-indicator.disabled {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.registry-status-indicator i {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.registry-config-body {
|
||||
padding: 1rem;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.registry-config-actions {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Toggle 开关样式 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.3s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
/* 现代表单样式 */
|
||||
.modern-form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
@@ -3899,68 +4146,96 @@
|
||||
|
||||
/* 测试控制卡片 */
|
||||
.test-controls-card {
|
||||
background: var(--container-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light, #e5e7eb);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border-radius: 12px;
|
||||
padding: 1.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.test-controls-card .test-controls-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-end;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.test-controls-card .test-controls-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.test-controls-card .form-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.test-controls-card .form-group.btn-group {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.test-controls-card .form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.test-controls-card .form-group label i {
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary-color);
|
||||
opacity: 0.7;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.test-controls-card .form-select,
|
||||
.test-controls-card .form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.95rem;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--input-bg, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-controls-card .form-select:focus,
|
||||
.test-controls-card .form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(61, 124, 244, 0.15);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
outline: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.test-controls-card .start-test-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
padding: 0.65rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: auto;
|
||||
white-space: nowrap;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.test-controls-card .start-test-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.test-controls-card .start-test-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 测试结果容器美化 */
|
||||
@@ -4369,26 +4644,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理地址配置卡片 -->
|
||||
<!-- Registry 平台配置卡片 -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-icon blue">
|
||||
<i class="fas fa-server"></i>
|
||||
<div class="config-card-icon purple">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</div>
|
||||
<div class="config-card-title">
|
||||
<h3>代理地址</h3>
|
||||
<p>配置Docker镜像代理服务器</p>
|
||||
<h3>Registry 平台配置</h3>
|
||||
<p>配置支持的容器镜像仓库平台及其代理地址</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="modern-form-group">
|
||||
<label for="proxyDomain"><i class="fas fa-globe"></i> 代理地址 <span class="required-badge">必填</span></label>
|
||||
<input type="text" id="proxyDomain" name="proxyDomain" class="modern-input" placeholder="例如: proxy.example.com" required>
|
||||
<span class="input-hint">填写您的Docker镜像代理服务器地址</span>
|
||||
<div class="registry-config-list" id="registryConfigList">
|
||||
<!-- Registry 配置项将在这里动态加载 -->
|
||||
<div class="loading-indicator">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</div>
|
||||
</div>
|
||||
<div class="registry-config-actions">
|
||||
<button type="button" class="btn btn-primary btn-with-icon" onclick="saveRegistryConfigs()">
|
||||
<i class="fas fa-save"></i> 保存 Registry 配置
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-with-icon" onclick="validateAndSaveConfig('proxy')">
|
||||
<i class="fas fa-save"></i> 保存代理地址
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4481,9 +4759,12 @@
|
||||
<!-- 选项将由 networkTest.initNetworkTest() 动态生成 -->
|
||||
</select>
|
||||
</div>
|
||||
<button id="startTestBtn" class="btn btn-primary start-test-btn">
|
||||
<i class="fas fa-play"></i> 开始测试
|
||||
</button>
|
||||
<div class="form-group btn-group">
|
||||
<label> </label>
|
||||
<button id="startTestBtn" class="btn btn-primary start-test-btn">
|
||||
<i class="fas fa-play"></i> 开始测试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4916,6 +5197,214 @@
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
<script>
|
||||
// Registry 配置管理
|
||||
let registryConfigs = [];
|
||||
|
||||
// 加载 Registry 配置
|
||||
async function loadRegistryConfigs() {
|
||||
console.log('loadRegistryConfigs called');
|
||||
try {
|
||||
const response = await fetch('/api/config/registry-configs');
|
||||
console.log('loadRegistryConfigs response status:', response.status);
|
||||
if (response.ok) {
|
||||
registryConfigs = await response.json();
|
||||
console.log('loadRegistryConfigs data:', registryConfigs);
|
||||
renderRegistryConfigs();
|
||||
} else {
|
||||
console.error('加载 Registry 配置失败, status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 Registry 配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染 Registry 配置列表
|
||||
function renderRegistryConfigs() {
|
||||
const container = document.getElementById('registryConfigList');
|
||||
if (!container) {
|
||||
console.error('Registry config container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registryConfigs || registryConfigs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p>暂无配置</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = registryConfigs.map((config, index) => {
|
||||
const isEnabled = config.enabled || false;
|
||||
const proxyValue = config.proxyUrl || '';
|
||||
const statusClass = isEnabled ? 'enabled' : 'disabled';
|
||||
const statusIcon = isEnabled ? 'fa-check-circle' : 'fa-times-circle';
|
||||
const statusText = isEnabled ? '已启用' : '已禁用';
|
||||
|
||||
return `
|
||||
<div class="registry-config-item ${statusClass}" data-registry-id="${config.registryId}">
|
||||
<div class="registry-config-header">
|
||||
<div class="registry-config-info">
|
||||
<span class="registry-badge" style="background: linear-gradient(135deg, ${config.color || '#666'}, ${adjustColor(config.color || '#666', -20)})">
|
||||
<i class="${config.icon || 'fas fa-cube'}"></i>
|
||||
</span>
|
||||
<div class="registry-config-details">
|
||||
<h4>${config.name || 'Unknown Registry'}</h4>
|
||||
<small class="registry-prefix">${config.prefix || 'Docker Hub'}</small>
|
||||
<p>${config.description || ''}</p>
|
||||
</div>
|
||||
<div class="registry-status-indicator ${statusClass}">
|
||||
<i class="fas ${statusIcon}"></i>
|
||||
<span>${statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="registry-config-body"
|
||||
id="registry-body-${config.registryId}"
|
||||
style="display: block;">
|
||||
<div class="modern-form-group">
|
||||
<label for="registry-proxy-${config.registryId}">
|
||||
<i class="fas fa-link"></i> 代理地址
|
||||
<span class="required-badge">必填</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
id="registry-proxy-${config.registryId}"
|
||||
class="modern-input"
|
||||
placeholder="例如: ${config.prefix ? config.prefix.replace(/\./g, '-') + '.proxy.example.com' : 'dockerhub.proxy.example.com'}"
|
||||
value="${proxyValue}"
|
||||
${isEnabled ? 'required' : ''}>
|
||||
<span class="input-hint">填写此 Registry 的代理服务器地址</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1rem;">
|
||||
<label class="toggle-switch" style="margin: 0;">
|
||||
<input type="checkbox"
|
||||
id="registry-enabled-${config.registryId}"
|
||||
${isEnabled ? 'checked' : ''}
|
||||
onchange="event.stopPropagation(); toggleRegistryEnabled('${config.registryId}', this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span style="font-size: 0.85rem; color: #64748b; line-height: 26px;">启用此 Registry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 辅助函数:调整颜色亮度
|
||||
function adjustColor(color, amount) {
|
||||
const clamp = (num) => Math.min(Math.max(num, 0), 255);
|
||||
const num = parseInt(color.replace('#', ''), 16);
|
||||
const r = clamp((num >> 16) + amount);
|
||||
const g = clamp(((num >> 8) & 0x00FF) + amount);
|
||||
const b = clamp((num & 0x0000FF) + amount);
|
||||
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
// 切换 Registry body 显示/隐藏(点击头部时)
|
||||
function toggleRegistryBody(registryId) {
|
||||
const body = document.getElementById(`registry-body-${registryId}`);
|
||||
const checkbox = document.getElementById(`registry-enabled-${registryId}`);
|
||||
|
||||
// 只有在启用状态下才允许通过点击展开/收起
|
||||
if (checkbox && checkbox.checked && body) {
|
||||
const isVisible = body.style.display !== 'none';
|
||||
body.style.display = isVisible ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 Registry 启用状态
|
||||
function toggleRegistryEnabled(registryId, enabled) {
|
||||
console.log(`toggleRegistryEnabled: ${registryId}, enabled: ${enabled}`);
|
||||
|
||||
// 先保存所有输入框的当前值到内存中,避免重新渲染时丢失
|
||||
registryConfigs.forEach(config => {
|
||||
const proxyInput = document.getElementById(`registry-proxy-${config.registryId}`);
|
||||
if (proxyInput) {
|
||||
config.proxyUrl = proxyInput.value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
const config = registryConfigs.find(c => c.registryId === registryId);
|
||||
if (config) {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
|
||||
// 重新渲染以更新状态指示器和UI
|
||||
renderRegistryConfigs();
|
||||
}
|
||||
|
||||
// 保存 Registry 配置
|
||||
async function saveRegistryConfigs() {
|
||||
console.log('saveRegistryConfigs called, registryConfigs:', registryConfigs);
|
||||
|
||||
try {
|
||||
// 收集所有配置
|
||||
const configs = [];
|
||||
let validationError = null;
|
||||
|
||||
for (const config of registryConfigs) {
|
||||
const enabledCheckbox = document.getElementById(`registry-enabled-${config.registryId}`);
|
||||
const proxyInput = document.getElementById(`registry-proxy-${config.registryId}`);
|
||||
|
||||
const enabled = enabledCheckbox ? enabledCheckbox.checked : config.enabled;
|
||||
const proxyUrl = proxyInput ? proxyInput.value.trim() : (config.proxyUrl || '');
|
||||
|
||||
console.log(`Config ${config.registryId}: enabled=${enabled}, proxyUrl=${proxyUrl}`);
|
||||
|
||||
// 验证:如果启用了但没有填写代理地址
|
||||
if (enabled && !proxyUrl) {
|
||||
validationError = `请填写 ${config.name} 的代理地址`;
|
||||
break;
|
||||
}
|
||||
|
||||
configs.push({
|
||||
registryId: config.registryId,
|
||||
enabled: enabled,
|
||||
proxyUrl: proxyUrl
|
||||
});
|
||||
}
|
||||
|
||||
if (validationError) {
|
||||
if (window.app && typeof window.app.showNotification === 'function') {
|
||||
window.app.showNotification(validationError, 'error');
|
||||
} else {
|
||||
alert(validationError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending configs:', configs);
|
||||
|
||||
const response = await fetch('/api/config/registry-configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ configs })
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
if (window.app && typeof window.app.showNotification === 'function') {
|
||||
window.app.showNotification('Registry 配置保存成功', 'success');
|
||||
} else {
|
||||
alert('Registry 配置保存成功');
|
||||
}
|
||||
// 重新加载配置
|
||||
await loadRegistryConfigs();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.details || error.message || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveRegistryConfigs error:', error);
|
||||
if (window.app && typeof window.app.showNotification === 'function') {
|
||||
window.app.showNotification(error.message || '保存 Registry 配置失败', 'error');
|
||||
} else {
|
||||
alert(error.message || '保存 Registry 配置失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在DOM加载完成后初始化登录表单
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
@@ -4962,6 +5451,17 @@
|
||||
window.validateAndSaveConfig = window.app ? window.app.validateAndSaveConfig : function(type) {
|
||||
console.error('validateAndSaveConfig未定义');
|
||||
};
|
||||
|
||||
// 加载 Registry 配置
|
||||
loadRegistryConfigs();
|
||||
|
||||
// 将 Registry 相关函数暴露到 window 对象
|
||||
window.loadRegistryConfigs = loadRegistryConfigs;
|
||||
window.saveRegistryConfigs = saveRegistryConfigs;
|
||||
window.toggleRegistryEnabled = toggleRegistryEnabled;
|
||||
window.toggleRegistryBody = toggleRegistryBody;
|
||||
window.renderRegistryConfigs = renderRegistryConfigs;
|
||||
window.adjustColor = adjustColor;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -75,15 +75,28 @@
|
||||
|
||||
<!-- 搜索内容 -->
|
||||
<div id="searchContent" class="content">
|
||||
<!-- Registry 平台选择器 -->
|
||||
<div class="registry-selector">
|
||||
<div class="registry-tabs" id="registryTabs">
|
||||
<!-- Registry 选项卡将由 JavaScript 动态生成 -->
|
||||
<div class="loading-indicator"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput"
|
||||
placeholder="输入关键词搜索Docker镜像,例如:nginx、mysql、redis..."
|
||||
onkeypress="if(event.key === 'Enter') searchDockerHub(1)">
|
||||
<button onclick="searchDockerHub(1)">
|
||||
placeholder="输入关键词搜索镜像,例如:nginx、mysql、redis..."
|
||||
onkeypress="if(event.key === 'Enter') searchImages(1)">
|
||||
<button onclick="searchImages(1)">
|
||||
<i class="fas fa-search"></i> 搜索镜像
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 当前选中的 Registry 信息 -->
|
||||
<div id="currentRegistryInfo" class="current-registry-info" style="display: none;">
|
||||
<span id="registryInfoText"></span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果容器 -->
|
||||
<div id="searchResultsContainer">
|
||||
<!-- 搜索结果列表 -->
|
||||
@@ -359,6 +372,614 @@
|
||||
let totalPages = 1;
|
||||
let currentTagPage = 1;
|
||||
let currentImageData = null;
|
||||
let currentRegistry = 'docker-hub'; // 当前选中的 Registry
|
||||
let enabledRegistries = []; // 启用的 Registry 列表
|
||||
|
||||
// Registry 配置信息(默认配置,会被后端配置覆盖)
|
||||
const REGISTRY_INFO = {
|
||||
'docker-hub': { name: 'Docker Hub', icon: 'fab fa-docker', color: '#2496ED', prefix: '', proxyUrl: '' },
|
||||
'ghcr': { name: 'GitHub Container Registry', icon: 'fab fa-github', color: '#333', prefix: 'ghcr.io', proxyUrl: '' },
|
||||
'quay': { name: 'Quay.io', icon: 'fas fa-cube', color: '#40B4E5', prefix: 'quay.io', proxyUrl: '' },
|
||||
'k8s': { name: 'Kubernetes Registry', icon: 'fas fa-dharmachakra', color: '#326CE5', prefix: 'registry.k8s.io', proxyUrl: '' },
|
||||
'gcr': { name: 'Google Container Registry', icon: 'fab fa-google', color: '#4285F4', prefix: 'gcr.io', proxyUrl: '' },
|
||||
'mcr': { name: 'Microsoft Container Registry', icon: 'fab fa-microsoft', color: '#00A4EF', prefix: 'mcr.microsoft.com', proxyUrl: '' },
|
||||
'elastic': { name: 'Elastic Container Registry', icon: 'fas fa-bolt', color: '#FEC514', prefix: 'docker.elastic.co', proxyUrl: '' },
|
||||
'nvcr': { name: 'NVIDIA Container Registry', icon: 'fas fa-microchip', color: '#76B900', prefix: 'nvcr.io', proxyUrl: '' },
|
||||
'all': { name: '所有平台', icon: 'fas fa-globe', color: '#6c757d', prefix: '', proxyUrl: '' }
|
||||
};
|
||||
|
||||
// 加载启用的 Registry 配置
|
||||
async function loadEnabledRegistries() {
|
||||
try {
|
||||
const response = await fetch('/api/config/registry-configs/enabled');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Loaded enabled registries:', data);
|
||||
|
||||
// 如果返回空数组,使用默认配置
|
||||
if (!data || data.length === 0) {
|
||||
enabledRegistries = [{ registryId: 'docker-hub', name: 'Docker Hub', icon: 'fab fa-docker', color: '#2496ED', prefix: '' }];
|
||||
} else {
|
||||
enabledRegistries = data;
|
||||
|
||||
// 更新 REGISTRY_INFO 中的代理地址
|
||||
enabledRegistries.forEach(config => {
|
||||
console.log(`Registry ${config.registryId}: proxyUrl = ${config.proxyUrl}`);
|
||||
if (REGISTRY_INFO[config.registryId]) {
|
||||
REGISTRY_INFO[config.registryId].proxyUrl = config.proxyUrl || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Updated REGISTRY_INFO:', REGISTRY_INFO);
|
||||
console.log('Updated enabledRegistries:', enabledRegistries);
|
||||
|
||||
renderRegistryTabs();
|
||||
} else {
|
||||
// 如果加载失败,显示默认的 Docker Hub
|
||||
enabledRegistries = [{ registryId: 'docker-hub', name: 'Docker Hub', icon: 'fab fa-docker', color: '#2496ED', prefix: '' }];
|
||||
renderRegistryTabs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 Registry 配置失败:', error);
|
||||
// 如果加载失败,显示默认的 Docker Hub
|
||||
enabledRegistries = [{ registryId: 'docker-hub', name: 'Docker Hub', icon: 'fab fa-docker', color: '#2496ED', prefix: '' }];
|
||||
renderRegistryTabs();
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染 Registry 选择标签
|
||||
function renderRegistryTabs() {
|
||||
const tabsContainer = document.getElementById('registryTabs');
|
||||
if (!tabsContainer) return;
|
||||
|
||||
let tabsHtml = '';
|
||||
|
||||
// 渲染启用的 Registry 标签
|
||||
enabledRegistries.forEach((config, index) => {
|
||||
const isActive = index === 0 ? 'active' : '';
|
||||
const info = REGISTRY_INFO[config.registryId] || config;
|
||||
const shortName = config.name.replace(' Container Registry', '').replace(' Registry', '');
|
||||
|
||||
tabsHtml += `
|
||||
<button class="registry-tab ${isActive}" data-registry="${config.registryId}" onclick="selectRegistry('${config.registryId}')">
|
||||
<i class="${info.icon || config.icon}"></i>
|
||||
<span>${shortName}</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
|
||||
// 如果有多个启用的 Registry,添加"全部"选项
|
||||
if (enabledRegistries.length > 1) {
|
||||
tabsHtml += `
|
||||
<button class="registry-tab" data-registry="all" onclick="selectRegistry('all')">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span>全部</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
tabsContainer.innerHTML = tabsHtml;
|
||||
|
||||
// 设置默认选中的 Registry
|
||||
if (enabledRegistries.length > 0) {
|
||||
currentRegistry = enabledRegistries[0].registryId;
|
||||
}
|
||||
}
|
||||
|
||||
// 选择 Registry 平台
|
||||
function selectRegistry(registryId) {
|
||||
currentRegistry = registryId;
|
||||
currentPage = 1;
|
||||
currentSearchTerm = '';
|
||||
|
||||
// 更新选项卡样式
|
||||
document.querySelectorAll('.registry-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
if (tab.dataset.registry === registryId) {
|
||||
tab.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新搜索框占位符
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const info = REGISTRY_INFO[registryId];
|
||||
if (registryId === 'all') {
|
||||
searchInput.placeholder = '输入关键词在所有平台搜索镜像...';
|
||||
} else {
|
||||
searchInput.placeholder = `在 ${info.name} 中搜索镜像...`;
|
||||
}
|
||||
|
||||
// 显示当前 Registry 信息
|
||||
const registryInfoEl = document.getElementById('currentRegistryInfo');
|
||||
const registryInfoText = document.getElementById('registryInfoText');
|
||||
if (registryId !== 'docker-hub' && registryId !== 'all') {
|
||||
registryInfoEl.style.display = 'block';
|
||||
registryInfoText.innerHTML = `<i class="${info.icon}" style="color: ${info.color}"></i> 当前搜索平台: <strong>${info.name}</strong> (镜像前缀: ${info.prefix})`;
|
||||
} else if (registryId === 'all') {
|
||||
registryInfoEl.style.display = 'block';
|
||||
registryInfoText.innerHTML = `<i class="${info.icon}"></i> 将在所有支持的 Registry 平台中搜索`;
|
||||
} else {
|
||||
registryInfoEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// 清空搜索结果
|
||||
document.getElementById('searchResults').innerHTML = '';
|
||||
document.getElementById('paginationContainer').style.display = 'none';
|
||||
document.querySelector('#searchContent .features').style.display = 'grid';
|
||||
}
|
||||
window.selectRegistry = selectRegistry;
|
||||
|
||||
// 统一搜索入口函数
|
||||
async function searchImages(page = 1) {
|
||||
if (currentRegistry === 'docker-hub') {
|
||||
// 保持对 Docker Hub 的原有搜索逻辑
|
||||
await searchDockerHub(page);
|
||||
} else if (currentRegistry === 'all') {
|
||||
// 搜索所有平台
|
||||
await searchAllRegistries(page);
|
||||
} else {
|
||||
// 搜索特定 Registry
|
||||
await searchSpecificRegistry(currentRegistry, page);
|
||||
}
|
||||
}
|
||||
window.searchImages = searchImages;
|
||||
|
||||
// 搜索所有 Registry 平台
|
||||
async function searchAllRegistries(page = 1) {
|
||||
const searchTerm = document.getElementById('searchInput').value.trim();
|
||||
if (!searchTerm) {
|
||||
showToastNotification('请输入搜索关键词', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSearchTerm !== searchTerm) {
|
||||
page = 1;
|
||||
currentSearchTerm = searchTerm;
|
||||
}
|
||||
|
||||
currentPage = page;
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
searchResults.innerHTML = '<div class="loading-indicator"><i class="fas fa-spinner fa-spin"></i> 正在搜索所有平台...</div>';
|
||||
searchResults.style.display = 'block';
|
||||
document.querySelector('#searchContent .features').style.display = 'none';
|
||||
document.getElementById('searchResultsList').style.display = 'block';
|
||||
document.getElementById('imageTagsView').style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = await fetchWithRetry(
|
||||
`/api/registry/search-all?term=${encodeURIComponent(searchTerm)}&page=${page}&limit=5`
|
||||
);
|
||||
|
||||
searchResults.innerHTML = '';
|
||||
let hasResults = false;
|
||||
|
||||
// 按 Registry 分组显示结果
|
||||
data.registries.forEach(registry => {
|
||||
if (registry.results && registry.results.length > 0) {
|
||||
hasResults = true;
|
||||
const info = REGISTRY_INFO[registry.registry] || {};
|
||||
|
||||
// 创建 Registry 分组标题
|
||||
const groupHeader = document.createElement('div');
|
||||
groupHeader.className = 'registry-group-header';
|
||||
groupHeader.innerHTML = `
|
||||
<i class="${info.icon || 'fas fa-cube'}" style="color: ${info.color || '#666'}"></i>
|
||||
<span>${registry.registryName || registry.registry}</span>
|
||||
<span class="result-count">${registry.count} 个结果</span>
|
||||
`;
|
||||
searchResults.appendChild(groupHeader);
|
||||
|
||||
// 显示该 Registry 的结果
|
||||
registry.results.forEach(result => {
|
||||
searchResults.appendChild(createMultiRegistryResultItem(result, registry.registry));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasResults) {
|
||||
searchResults.innerHTML = '<div class="empty-result"><i class="fas fa-search"></i><p>在所有平台中未找到匹配的镜像</p></div>';
|
||||
}
|
||||
|
||||
// 聚合搜索不显示分页
|
||||
document.getElementById('paginationContainer').style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
console.error('聚合搜索出错:', error);
|
||||
searchResults.innerHTML = `
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>搜索时发生错误: ${error.message}</p>
|
||||
<button onclick="searchAllRegistries(${page})" class="retry-btn">
|
||||
<i class="fas fa-redo"></i> 重试
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
window.searchAllRegistries = searchAllRegistries;
|
||||
|
||||
// 搜索特定 Registry
|
||||
async function searchSpecificRegistry(registryId, page = 1) {
|
||||
const searchTerm = document.getElementById('searchInput').value.trim();
|
||||
if (!searchTerm) {
|
||||
showToastNotification('请输入搜索关键词', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSearchTerm !== searchTerm) {
|
||||
page = 1;
|
||||
currentSearchTerm = searchTerm;
|
||||
}
|
||||
|
||||
currentPage = page;
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
const info = REGISTRY_INFO[registryId] || {};
|
||||
searchResults.innerHTML = `<div class="loading-indicator"><i class="fas fa-spinner fa-spin"></i> 正在搜索 ${info.name}...</div>`;
|
||||
searchResults.style.display = 'block';
|
||||
document.querySelector('#searchContent .features').style.display = 'none';
|
||||
document.getElementById('searchResultsList').style.display = 'block';
|
||||
document.getElementById('imageTagsView').style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = await fetchWithRetry(
|
||||
`/api/registry/search/${registryId}?term=${encodeURIComponent(searchTerm)}&page=${page}&limit=25`
|
||||
);
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
searchResults.innerHTML = '';
|
||||
|
||||
// 创建 Registry 分组标题
|
||||
const groupHeader = document.createElement('div');
|
||||
groupHeader.className = 'registry-group-header';
|
||||
groupHeader.innerHTML = `
|
||||
<i class="${info.icon}" style="color: ${info.color}"></i>
|
||||
<span>${info.name}</span>
|
||||
<span class="result-count">${data.count} 个结果</span>
|
||||
`;
|
||||
searchResults.appendChild(groupHeader);
|
||||
|
||||
data.results.forEach(result => {
|
||||
searchResults.appendChild(createMultiRegistryResultItem(result, registryId));
|
||||
});
|
||||
|
||||
// 更新分页(对于支持分页的 Registry)
|
||||
totalPages = Math.ceil((data.count || data.results.length) / 25);
|
||||
if (totalPages > 1) {
|
||||
updatePagination(page, totalPages);
|
||||
document.getElementById('paginationContainer').style.display = 'flex';
|
||||
|
||||
// 更新分页按钮的事件
|
||||
document.getElementById('prevPageBtn').onclick = () => searchSpecificRegistry(registryId, currentPage - 1);
|
||||
document.getElementById('nextPageBtn').onclick = () => searchSpecificRegistry(registryId, currentPage + 1);
|
||||
} else {
|
||||
document.getElementById('paginationContainer').style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
searchResults.innerHTML = `<div class="empty-result"><i class="fas fa-search"></i><p>在 ${info.name} 中未找到匹配的镜像</p></div>`;
|
||||
document.getElementById('paginationContainer').style.display = 'none';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`搜索 ${registryId} 出错:`, error);
|
||||
searchResults.innerHTML = `
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>搜索时发生错误: ${error.message}</p>
|
||||
<button onclick="searchSpecificRegistry('${registryId}', ${page})" class="retry-btn">
|
||||
<i class="fas fa-redo"></i> 重试
|
||||
</button>
|
||||
</div>`;
|
||||
document.getElementById('paginationContainer').style.display = 'none';
|
||||
}
|
||||
}
|
||||
window.searchSpecificRegistry = searchSpecificRegistry;
|
||||
|
||||
// 创建多 Registry 搜索结果项
|
||||
function createMultiRegistryResultItem(result, registryId) {
|
||||
const resultItem = document.createElement('div');
|
||||
resultItem.className = `search-result-item ${result.isOfficial ? 'official-image' : ''}`;
|
||||
|
||||
const info = REGISTRY_INFO[registryId] || {};
|
||||
const description = result.description || '暂无描述';
|
||||
const pullCommand = result.pullCommand || (info.prefix ? `${info.prefix}/${result.fullName || result.name}` : result.name);
|
||||
|
||||
resultItem.innerHTML = `
|
||||
<div class="result-header">
|
||||
<div class="title-badge">
|
||||
<span class="registry-badge" style="background-color: ${info.color || '#666'}">
|
||||
<i class="${info.icon || 'fas fa-cube'}"></i>
|
||||
</span>
|
||||
<h3>${result.fullName || result.name}</h3>
|
||||
${result.isOfficial ? '<span class="official-badge"><i class="fas fa-check-circle"></i> 官方</span>' : ''}
|
||||
</div>
|
||||
<div class="result-stats">
|
||||
${result.stars > 0 ? `<span class="stats"><i class="fas fa-star"></i> ${formatNumber(result.stars)}</span>` : ''}
|
||||
${result.pulls > 0 ? `<span class="stats"><i class="fas fa-download"></i> ${formatNumber(result.pulls)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p class="result-description">${description}</p>
|
||||
<div class="result-pull-command">
|
||||
<code>${pullCommand}</code>
|
||||
<button class="copy-small-btn" onclick="copyToClipboard('${pullCommand.replace(/'/g, "\\'")}', this); event.stopPropagation();">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="action-btn primary" onclick="useImageFromRegistry('${pullCommand.replace(/'/g, "\\'")}', '${registryId}')">
|
||||
<i class="fas fa-rocket"></i> 使用此镜像
|
||||
</button>
|
||||
${registryId === 'docker-hub' || registryId === 'quay' ? `
|
||||
<button class="action-btn secondary" onclick="viewImageDetailsForRegistry('${(result.fullName || result.name).replace(/'/g, "\\'")}', ${result.isOfficial || false}, '${encodeURIComponent(description).replace(/'/g, "%27")}', ${result.stars || 0}, ${result.pulls || 0}, '${registryId}')">
|
||||
<i class="fas fa-tags"></i> 查看标签
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
return resultItem;
|
||||
}
|
||||
|
||||
// 从特定 Registry 使用镜像
|
||||
function useImageFromRegistry(pullCommand, registryId) {
|
||||
switchTab('accelerate');
|
||||
|
||||
const imageInput = document.getElementById('imageInput');
|
||||
if (imageInput) {
|
||||
imageInput.value = pullCommand;
|
||||
generateCommands(pullCommand);
|
||||
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
showToastNotification(`已选择镜像: ${pullCommand}`, 'success');
|
||||
}
|
||||
window.useImageFromRegistry = useImageFromRegistry;
|
||||
|
||||
// 查看特定 Registry 的镜像标签详情
|
||||
async function viewImageDetailsForRegistry(imageName, isOfficial, description, stars, pulls, registryId) {
|
||||
if (registryId === 'docker-hub') {
|
||||
// 使用原有的 Docker Hub 标签查看逻辑
|
||||
viewImageDetails(imageName, isOfficial, description, stars, pulls);
|
||||
} else {
|
||||
// 其他 Registry 使用新的标签查看逻辑
|
||||
currentImageData = {
|
||||
name: imageName,
|
||||
isOfficial: isOfficial,
|
||||
description: decodeURIComponent(description || ''),
|
||||
stars: stars,
|
||||
pulls: pulls,
|
||||
registry: registryId
|
||||
};
|
||||
|
||||
const imageTagsView = document.getElementById('imageTagsView');
|
||||
const info = REGISTRY_INFO[registryId] || {};
|
||||
imageTagsView.innerHTML = `<div class="loading-container"><div class="loading-indicator"><i class="fas fa-spinner fa-spin"></i> 正在加载 ${info.name} 镜像标签...</div></div>`;
|
||||
document.getElementById('searchResultsList').style.display = 'none';
|
||||
imageTagsView.style.display = 'block';
|
||||
|
||||
try {
|
||||
const data = await fetchWithRetry(
|
||||
`/api/registry/tags/${registryId}?name=${encodeURIComponent(imageName)}`
|
||||
);
|
||||
|
||||
const pullPrefix = info.prefix ? `${info.prefix}/${imageName}` : imageName;
|
||||
|
||||
const tagCount = data.count || data.results?.length || 0;
|
||||
|
||||
// 根据标签数量判断是否显示警告
|
||||
let warningMessage = '';
|
||||
if (tagCount > 1000) {
|
||||
warningMessage = `<div class="tag-count-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>该镜像包含 <strong>${tagCount}</strong> 个标签,加载全部可能会很慢。建议使用搜索功能查找特定标签。</p>
|
||||
</div>`;
|
||||
} else if (tagCount > 500) {
|
||||
warningMessage = `<div class="tag-count-warning moderate">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>该镜像包含 <strong>${tagCount}</strong> 个标签。</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 保存当前 Registry 标签数据供筛选使用
|
||||
window.currentRegistryTags = data.results || [];
|
||||
window.currentRegistryPullPrefix = pullPrefix;
|
||||
window.currentRegistryId = registryId;
|
||||
|
||||
imageTagsView.innerHTML = `
|
||||
<div class="tag-header">
|
||||
<div class="tag-breadcrumb">
|
||||
<a href="javascript:void(0);" onclick="showSearchResults()">返回搜索结果</a>
|
||||
</div>
|
||||
<h2 id="currentImageTitle">
|
||||
<span class="registry-badge" style="background-color: ${info.color || '#666'}">
|
||||
<i class="${info.icon || 'fas fa-cube'}"></i>
|
||||
</span>
|
||||
${imageName}
|
||||
</h2>
|
||||
<p id="imageDescription" class="image-description">${currentImageData.description || '暂无描述'}</p>
|
||||
<div class="image-meta">
|
||||
${currentImageData.stars > 0 ? `<span id="imageStars"><i class="fas fa-star"></i> ${formatNumber(currentImageData.stars)} 星标</span>` : ''}
|
||||
${currentImageData.pulls > 0 ? `<span id="imagePulls"><i class="fas fa-download"></i> ${formatNumber(currentImageData.pulls)} 下载</span>` : ''}
|
||||
<span id="imageTags"><i class="fas fa-tags"></i> ${formatNumber(tagCount)} 个标签</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${warningMessage}
|
||||
|
||||
<div class="tag-actions">
|
||||
<div class="tag-search-container">
|
||||
<input type="text" id="tagSearchInput" placeholder="搜索TAG..." onkeyup="filterRegistryTags()">
|
||||
<button class="reset-search-btn" onclick="resetRegistryTagSearch()">
|
||||
<i class="fas fa-times"></i> 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-search-stats" id="registryTagStats">
|
||||
<p>共找到 <strong>${tagCount}</strong> 个标签</p>
|
||||
</div>
|
||||
|
||||
<div class="tag-table-container">
|
||||
<table class="tag-table" id="registryTagTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="25%">TAG</th>
|
||||
<th width="35%">OS/ARCH</th>
|
||||
<th width="15%">大小</th>
|
||||
<th width="15%">更新时间</th>
|
||||
<th width="10%">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="registryTagTableBody">
|
||||
<!-- 标签将通过 JS 渲染 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 渲染标签表格
|
||||
renderRegistryTagsTable(data.results || [], pullPrefix, registryId);
|
||||
enhanceTagSearchContainer();
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取镜像标签失败:', error);
|
||||
imageTagsView.innerHTML = `
|
||||
<div class="tag-header">
|
||||
<div class="tag-breadcrumb">
|
||||
<a href="javascript:void(0);" onclick="showSearchResults()">返回搜索结果</a>
|
||||
</div>
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>加载镜像标签失败: ${error.message}</p>
|
||||
<button onclick="viewImageDetailsForRegistry('${imageName.replace(/'/g, "\\'")}', ${isOfficial}, '${encodeURIComponent(description).replace(/'/g, "%27")}', ${stars}, ${pulls}, '${registryId}')" class="retry-btn">
|
||||
<i class="fas fa-redo"></i> 重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.viewImageDetailsForRegistry = viewImageDetailsForRegistry;
|
||||
|
||||
// 渲染 registry 标签表格(与 Docker Hub 相同的表格布局)
|
||||
function renderRegistryTagsTable(tags, pullPrefix, registryId) {
|
||||
const tbody = document.getElementById('registryTagTableBody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="no-tags-message">暂无标签数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = tags.map((tag, index) => {
|
||||
// 处理标签数据 - 标签可能是字符串或对象
|
||||
const tagName = typeof tag === 'string' ? tag : (tag.name || tag);
|
||||
const tagDigest = typeof tag === 'object' ? tag.digest : null;
|
||||
const tagSize = typeof tag === 'object' ? tag.size : null;
|
||||
const tagLastUpdated = typeof tag === 'object' ? tag.lastUpdated : null;
|
||||
const tagImages = typeof tag === 'object' && tag.images ? tag.images : [];
|
||||
|
||||
// 计算大小
|
||||
let sizeDisplay = '-';
|
||||
if (tagSize) {
|
||||
const sizeInMB = Math.round(tagSize / 1024 / 1024);
|
||||
sizeDisplay = `${sizeInMB} MB`;
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
let lastUpdatedDisplay = '-';
|
||||
if (tagLastUpdated) {
|
||||
const date = new Date(tagLastUpdated);
|
||||
if (!isNaN(date.getTime())) {
|
||||
lastUpdatedDisplay = date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 OS/ARCH 显示
|
||||
const osArchHtml = tagImages.length > 0
|
||||
? createOsArchHtml(tagImages, index)
|
||||
: '<span class="arch-badge">linux/amd64</span>';
|
||||
|
||||
return `
|
||||
<tr data-tag="${tagName}">
|
||||
<td><span class="tag-name-cell">${tagName}</span></td>
|
||||
<td>${osArchHtml}</td>
|
||||
<td>${sizeDisplay}</td>
|
||||
<td>${lastUpdatedDisplay}</td>
|
||||
<td>
|
||||
<button class="primary-btn" onclick="useTagFromRegistry('${pullPrefix}', '${tagName}', '${registryId}')">
|
||||
<i class="fas fa-rocket"></i> 使用
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 过滤 registry 标签(表格版本)
|
||||
function filterRegistryTags() {
|
||||
const searchInput = document.getElementById('tagSearchInput');
|
||||
const tbody = document.getElementById('registryTagTableBody');
|
||||
if (!searchInput || !tbody) return;
|
||||
|
||||
const searchTerm = searchInput.value.toLowerCase().trim();
|
||||
const rows = tbody.querySelectorAll('tr');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const tagName = row.getAttribute('data-tag');
|
||||
if (!tagName) return;
|
||||
|
||||
if (searchTerm === '' || tagName.toLowerCase().includes(searchTerm)) {
|
||||
row.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 更新统计信息
|
||||
const statsDiv = document.getElementById('registryTagStats');
|
||||
if (statsDiv) {
|
||||
const totalCount = rows.length;
|
||||
if (searchTerm) {
|
||||
statsDiv.innerHTML = `<p>搜索结果: <strong>${visibleCount}</strong> / ${totalCount} 个标签</p>`;
|
||||
} else {
|
||||
statsDiv.innerHTML = `<p>共找到 <strong>${totalCount}</strong> 个标签</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置 registry 标签搜索
|
||||
function resetRegistryTagSearch() {
|
||||
const searchInput = document.getElementById('tagSearchInput');
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
filterRegistryTags();
|
||||
}
|
||||
}
|
||||
|
||||
// 使用指定 registry 的标签
|
||||
function useTagFromRegistry(pullPrefix, tag, registryId) {
|
||||
const fullImage = `${pullPrefix}:${tag}`;
|
||||
const imageInput = document.getElementById('imageInput');
|
||||
if (imageInput) {
|
||||
imageInput.value = fullImage;
|
||||
// 跳转到镜像加速页面
|
||||
switchTab('accelerate');
|
||||
// 生成加速命令
|
||||
setTimeout(() => {
|
||||
generateCommands();
|
||||
showNotification(`已选择镜像: ${fullImage}`, 'success');
|
||||
}, 100);
|
||||
} else {
|
||||
console.error('imageInput element not found');
|
||||
}
|
||||
}
|
||||
window.useTagFromRegistry = useTagFromRegistry;
|
||||
|
||||
// 初始化时加载代理域名配置
|
||||
async function initProxyDomain() {
|
||||
@@ -521,7 +1142,7 @@
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// 生成加速命令
|
||||
// 生成加速命令 - 支持多 Registry 镜像格式
|
||||
function generateCommands(imageNameInput) {
|
||||
let currentImageName = imageNameInput;
|
||||
if (!currentImageName) {
|
||||
@@ -533,15 +1154,77 @@
|
||||
alert('请输入 Docker 镜像名称');
|
||||
return;
|
||||
}
|
||||
let [imageName, tag] = currentImageName.split(':');
|
||||
tag = tag || 'latest';
|
||||
|
||||
// 解析镜像名称和标签
|
||||
let imageName, tag;
|
||||
const lastColonIndex = currentImageName.lastIndexOf(':');
|
||||
// 检查冒号后面是不是端口号(如果包含 / 则不是标签)
|
||||
if (lastColonIndex > -1 && !currentImageName.substring(lastColonIndex + 1).includes('/')) {
|
||||
imageName = currentImageName.substring(0, lastColonIndex);
|
||||
tag = currentImageName.substring(lastColonIndex + 1);
|
||||
} else {
|
||||
imageName = currentImageName;
|
||||
tag = 'latest';
|
||||
}
|
||||
|
||||
let originalImage = `${imageName}:${tag}`;
|
||||
let proxyImage = '';
|
||||
if (!imageName.includes('/')) {
|
||||
proxyImage = `${proxyDomain}/library/${imageName}:${tag}`;
|
||||
let detectedRegistry = 'docker-hub';
|
||||
|
||||
// 从 enabledRegistries 获取 Docker Hub 的代理地址作为默认值
|
||||
const dockerHubConfig = enabledRegistries.find(r => r.registryId === 'docker-hub');
|
||||
let usedProxyDomain = (dockerHubConfig && dockerHubConfig.proxyUrl) ? dockerHubConfig.proxyUrl : proxyDomain;
|
||||
|
||||
// Registry 前缀到 Registry ID 的映射
|
||||
const prefixToRegistryId = {
|
||||
'ghcr.io': 'ghcr',
|
||||
'quay.io': 'quay',
|
||||
'gcr.io': 'gcr',
|
||||
'registry.k8s.io': 'k8s',
|
||||
'k8s.gcr.io': 'k8s',
|
||||
'mcr.microsoft.com': 'mcr',
|
||||
'docker.elastic.co': 'elastic',
|
||||
'nvcr.io': 'nvcr',
|
||||
'docker.io': 'docker-hub'
|
||||
};
|
||||
|
||||
// 检测镜像来源
|
||||
let matchedPrefix = null;
|
||||
for (const prefix of Object.keys(prefixToRegistryId)) {
|
||||
if (imageName.startsWith(prefix + '/')) {
|
||||
matchedPrefix = prefix;
|
||||
detectedRegistry = prefixToRegistryId[prefix];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 查找对应 Registry 的代理地址
|
||||
if (detectedRegistry !== 'docker-hub') {
|
||||
const registryConfig = enabledRegistries.find(r => r.registryId === detectedRegistry);
|
||||
console.log(`Looking for registry ${detectedRegistry}, found:`, registryConfig);
|
||||
|
||||
if (registryConfig && registryConfig.proxyUrl) {
|
||||
usedProxyDomain = registryConfig.proxyUrl;
|
||||
console.log(`Using proxy URL: ${usedProxyDomain} for ${detectedRegistry}`);
|
||||
// 移除原始前缀,使用代理地址
|
||||
const imageWithoutPrefix = imageName.substring(matchedPrefix.length + 1);
|
||||
proxyImage = `${usedProxyDomain}/${imageWithoutPrefix}:${tag}`;
|
||||
} else {
|
||||
// 该 Registry 未启用或没有配置代理地址,使用原始地址
|
||||
console.warn(`No proxy URL configured for ${detectedRegistry}`);
|
||||
proxyImage = `${imageName}:${tag}`;
|
||||
showToastNotification(`${REGISTRY_INFO[detectedRegistry]?.name || detectedRegistry} 未配置代理地址,使用原始地址`, 'warning');
|
||||
}
|
||||
} else {
|
||||
proxyImage = `${proxyDomain}/${imageName}:${tag}`;
|
||||
// Docker Hub 镜像
|
||||
console.log(`Using Docker Hub proxy: ${usedProxyDomain}`);
|
||||
if (!imageName.includes('/')) {
|
||||
// 官方镜像
|
||||
proxyImage = `${usedProxyDomain}/library/${imageName}:${tag}`;
|
||||
} else {
|
||||
// 用户镜像
|
||||
proxyImage = `${usedProxyDomain}/${imageName}:${tag}`;
|
||||
}
|
||||
}
|
||||
|
||||
const commands = [
|
||||
@@ -555,6 +1238,20 @@
|
||||
const container = document.getElementById('commandsContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
// 显示检测到的 Registry 信息
|
||||
if (detectedRegistry !== 'docker-hub') {
|
||||
const registryInfo = REGISTRY_INFO[detectedRegistry] || {};
|
||||
const registryInfoDiv = document.createElement('div');
|
||||
registryInfoDiv.className = 'detected-registry-info';
|
||||
registryInfoDiv.innerHTML = `
|
||||
<div class="registry-detection-badge" style="background-color: ${registryInfo.color || '#666'}20; border-left: 3px solid ${registryInfo.color || '#666'};">
|
||||
<i class="${registryInfo.icon || 'fas fa-cube'}" style="color: ${registryInfo.color || '#666'}"></i>
|
||||
<span>检测到 <strong>${registryInfo.name || detectedRegistry}</strong> 镜像</span>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(registryInfoDiv);
|
||||
}
|
||||
|
||||
// 将生成的命令添加到结果容器中
|
||||
commands.forEach((command, index) => {
|
||||
const cmdDiv = document.createElement('div');
|
||||
@@ -568,7 +1265,7 @@
|
||||
<div class="terminal-button button-green"></div>
|
||||
</div>
|
||||
<pre><code>${command.cmd}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('${command.cmd}', this)">复制</button>
|
||||
<button class="copy-btn" onclick="copyToClipboard('${command.cmd.replace(/'/g, "\\'")}', this)">复制</button>
|
||||
</pre>
|
||||
</div>
|
||||
`;
|
||||
@@ -1871,6 +2568,9 @@
|
||||
// 初始化代理域名
|
||||
initProxyDomain();
|
||||
|
||||
// 加载启用的 Registry 配置
|
||||
loadEnabledRegistries();
|
||||
|
||||
// 确保元素存在再添加事件监听器
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
|
||||
@@ -304,6 +304,208 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Registry 平台选择器样式 ===== */
|
||||
.registry-selector {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.registry-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.registry-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.registry-tab:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
background: #f0f7ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.registry-tab.active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.registry-tab i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.registry-tab[data-registry="docker-hub"] i { color: #2496ED; }
|
||||
.registry-tab[data-registry="ghcr"] i { color: #333; }
|
||||
.registry-tab[data-registry="quay"] i { color: #40B4E5; }
|
||||
.registry-tab[data-registry="k8s"] i { color: #326CE5; }
|
||||
.registry-tab[data-registry="gcr"] i { color: #4285F4; }
|
||||
.registry-tab[data-registry="mcr"] i { color: #00A4EF; }
|
||||
.registry-tab[data-registry="elastic"] i { color: #FEC514; }
|
||||
.registry-tab[data-registry="nvcr"] i { color: #76B900; }
|
||||
.registry-tab[data-registry="all"] i { color: #6c757d; }
|
||||
|
||||
.registry-tab.active i {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* 当前 Registry 信息提示 */
|
||||
.current-registry-info {
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 14px;
|
||||
color: #1e40af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.current-registry-info i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Registry 分组标题 */
|
||||
.registry-group-header {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.registry-group-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.registry-group-header i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.registry-group-header span:first-of-type {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.registry-group-header .result-count {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
background: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Registry 徽章 */
|
||||
.registry-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 镜像拉取命令显示 */
|
||||
.result-pull-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.result-pull-command code {
|
||||
flex: 1;
|
||||
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-small-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.copy-small-btn:hover {
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* 响应式 Registry 选择器 */
|
||||
@media (max-width: 768px) {
|
||||
.registry-tabs {
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.registry-tab {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.registry-tab span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.registry-tab i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.registry-tabs {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.registry-tab {
|
||||
padding: 10px;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 搜索结果样式 - 现代设计 ===== */
|
||||
#searchResults {
|
||||
display: grid;
|
||||
@@ -517,6 +719,29 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 检测到的 Registry 信息样式 */
|
||||
.detected-registry-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.registry-detection-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.registry-detection-badge i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.registry-detection-badge strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 命令终端样式优化 */
|
||||
.command-terminal {
|
||||
background: #1F2937;
|
||||
@@ -597,12 +822,15 @@
|
||||
/* ===== 标签展示区域 - 现代设计 ===== */
|
||||
#imageTagsView {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tag-header {
|
||||
@@ -3237,6 +3465,288 @@
|
||||
.details-swal-popup .resource-details-table td {
|
||||
border-bottom: 1px dashed var(--border-light, #e5e7eb); /* 使用虚线分隔行 */
|
||||
}
|
||||
|
||||
/* ===== 多 Registry 标签网格样式 ===== */
|
||||
.tags-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-item:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.tag-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tag-date {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tag-item .tag-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.copy-tag-btn,
|
||||
.use-tag-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.copy-tag-btn {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.copy-tag-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.use-tag-btn {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.use-tag-btn:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 响应式标签网格 */
|
||||
@media (max-width: 640px) {
|
||||
.tags-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag-item .tag-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tag 操作行样式 */
|
||||
.tag-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-actions-row .search-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.tag-actions-row input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-actions-row input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.reset-search-btn {
|
||||
padding: 10px 16px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.reset-search-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* 无标签提示 */
|
||||
.no-tags-message {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Tag 数量警告 */
|
||||
.tag-count-warning {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border: 1px solid #fbbf24;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.tag-count-warning i {
|
||||
color: #f59e0b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ===== 加载指示器增强 ===== */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 40px 20px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.loading-indicator i {
|
||||
font-size: 18px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ===== 搜索结果操作按钮增强 ===== */
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-stats .stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.result-stats .stats i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-description {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-actions .action-btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.result-actions .action-btn.primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.result-actions .action-btn.primary:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.result-actions .action-btn.secondary {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.result-actions .action-btn.secondary:hover {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
/* --- 结束:资源详情表格特殊样式 --- */
|
||||
|
||||
/* --- 新增:资源详情类 Excel 表格样式 --- */
|
||||
|
||||
Reference in New Issue
Block a user