commit inicial do projeto
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
7
.erb/configs/.eslintrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off"
|
||||
}
|
||||
}
|
||||
54
.erb/configs/webpack.config.base.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Base webpack config used across other specific configs
|
||||
*/
|
||||
|
||||
import webpack from 'webpack';
|
||||
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import { dependencies as externals } from '../../release/app/package.json';
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
externals: [...Object.keys(externals || {})],
|
||||
|
||||
stats: 'errors-only',
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.[jt]sx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
// Remove this line to enable type checking in webpack builds
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'nodenext',
|
||||
moduleResolution: 'nodenext',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
output: {
|
||||
path: webpackPaths.srcPath,
|
||||
// https://github.com/webpack/webpack/issues/1114
|
||||
library: { type: 'commonjs2' },
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the array of extensions that should be used to resolve modules.
|
||||
*/
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
|
||||
modules: [webpackPaths.srcPath, 'node_modules'],
|
||||
// There is no need to add aliases here, the paths in tsconfig get mirrored
|
||||
plugins: [new TsconfigPathsPlugins()],
|
||||
},
|
||||
|
||||
plugins: [new webpack.EnvironmentPlugin({ NODE_ENV: 'production' })],
|
||||
};
|
||||
|
||||
export default configuration;
|
||||
3
.erb/configs/webpack.config.eslint.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint import/no-unresolved: off, import/no-self-import: off */
|
||||
|
||||
module.exports = require('./webpack.config.renderer.dev').default;
|
||||
63
.erb/configs/webpack.config.main.dev.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Webpack config for development electron main process
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import { merge } from 'webpack-merge';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
|
||||
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||
// at the dev webpack config is not accidentally run in a production environment
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
checkNodeEnv('development');
|
||||
}
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: 'electron-main',
|
||||
|
||||
entry: {
|
||||
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
|
||||
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
|
||||
},
|
||||
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
filename: '[name].bundle.dev.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
analyzerPort: 8888,
|
||||
}),
|
||||
|
||||
new webpack.DefinePlugin({
|
||||
'process.type': '"browser"',
|
||||
}),
|
||||
],
|
||||
|
||||
/**
|
||||
* Disables webpack processing of __dirname and __filename.
|
||||
* If you run the bundle in node.js it falls back to these values of node.js.
|
||||
* https://github.com/webpack/webpack/issues/2010
|
||||
*/
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
83
.erb/configs/webpack.config.main.prod.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Webpack config for production electron main process
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import { merge } from 'webpack-merge';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||
|
||||
checkNodeEnv('production');
|
||||
deleteSourceMaps();
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'source-map',
|
||||
|
||||
mode: 'production',
|
||||
|
||||
target: 'electron-main',
|
||||
|
||||
entry: {
|
||||
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
|
||||
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
|
||||
},
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distMainPath,
|
||||
filename: '[name].js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
analyzerPort: 8888,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_PROD: false,
|
||||
START_MINIMIZED: false,
|
||||
}),
|
||||
|
||||
new webpack.DefinePlugin({
|
||||
'process.type': '"browser"',
|
||||
}),
|
||||
],
|
||||
|
||||
/**
|
||||
* Disables webpack processing of __dirname and __filename.
|
||||
* If you run the bundle in node.js it falls back to these values of node.js.
|
||||
* https://github.com/webpack/webpack/issues/2010
|
||||
*/
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
71
.erb/configs/webpack.config.preload.dev.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import { merge } from 'webpack-merge';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
|
||||
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||
// at the dev webpack config is not accidentally run in a production environment
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
checkNodeEnv('development');
|
||||
}
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: 'electron-preload',
|
||||
|
||||
entry: path.join(webpackPaths.srcMainPath, 'preload.ts'),
|
||||
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
filename: 'preload.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*
|
||||
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
}),
|
||||
],
|
||||
|
||||
/**
|
||||
* Disables webpack processing of __dirname and __filename.
|
||||
* If you run the bundle in node.js it falls back to these values of node.js.
|
||||
* https://github.com/webpack/webpack/issues/2010
|
||||
*/
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
watch: true,
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
77
.erb/configs/webpack.config.renderer.dev.dll.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Builds the DLL for development electron renderer process
|
||||
*/
|
||||
|
||||
import webpack from 'webpack';
|
||||
import path from 'path';
|
||||
import { merge } from 'webpack-merge';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import { dependencies } from '../../package.json';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
|
||||
checkNodeEnv('development');
|
||||
|
||||
const dist = webpackPaths.dllPath;
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
context: webpackPaths.rootPath,
|
||||
|
||||
devtool: 'eval',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: 'electron-renderer',
|
||||
|
||||
externals: ['fsevents', 'crypto-browserify'],
|
||||
|
||||
/**
|
||||
* Use `module` from `webpack.config.renderer.dev.js`
|
||||
*/
|
||||
module: require('./webpack.config.renderer.dev').default.module,
|
||||
|
||||
entry: {
|
||||
renderer: Object.keys(dependencies || {}),
|
||||
},
|
||||
|
||||
output: {
|
||||
path: dist,
|
||||
filename: '[name].dev.dll.js',
|
||||
library: {
|
||||
name: 'renderer',
|
||||
type: 'var',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new webpack.DllPlugin({
|
||||
path: path.join(dist, '[name].json'),
|
||||
name: '[name]',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
options: {
|
||||
context: webpackPaths.srcPath,
|
||||
output: {
|
||||
path: webpackPaths.dllPath,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
225
.erb/configs/webpack.config.renderer.dev.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'webpack-dev-server';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import webpack from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import chalk from 'chalk';
|
||||
import { merge } from 'webpack-merge';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
|
||||
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||
// at the dev webpack config is not accidentally run in a production environment
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
checkNodeEnv('development');
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 1212;
|
||||
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
|
||||
const skipDLLs =
|
||||
module.parent?.filename.includes('webpack.config.renderer.dev.dll') ||
|
||||
module.parent?.filename.includes('webpack.config.eslint');
|
||||
|
||||
/**
|
||||
* Warn if the DLL is not built
|
||||
*/
|
||||
if (
|
||||
!skipDLLs &&
|
||||
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
|
||||
) {
|
||||
console.log(
|
||||
chalk.black.bgYellow.bold(
|
||||
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
|
||||
),
|
||||
);
|
||||
execSync('npm run postinstall');
|
||||
}
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'inline-source-map',
|
||||
|
||||
mode: 'development',
|
||||
|
||||
target: ['web', 'electron-renderer'],
|
||||
|
||||
entry: [
|
||||
`webpack-dev-server/client?http://localhost:${port}/dist`,
|
||||
'webpack/hot/only-dev-server',
|
||||
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
|
||||
],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: '/',
|
||||
filename: 'renderer.dev.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(c|a)ss$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?css$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader',
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
postcssOptions: {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
},
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// SVG
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
prettier: false,
|
||||
svgo: false,
|
||||
svgoConfig: {
|
||||
plugins: [{ removeViewBox: false }],
|
||||
},
|
||||
titleProp: true,
|
||||
ref: true,
|
||||
},
|
||||
},
|
||||
'file-loader',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
...(skipDLLs
|
||||
? []
|
||||
: [
|
||||
new webpack.DllReferencePlugin({
|
||||
context: webpackPaths.dllPath,
|
||||
manifest: require(manifest),
|
||||
sourceType: 'var',
|
||||
}),
|
||||
]),
|
||||
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*
|
||||
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'development',
|
||||
}),
|
||||
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
debug: true,
|
||||
}),
|
||||
|
||||
new ReactRefreshWebpackPlugin(),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.join('index.html'),
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
env: process.env.NODE_ENV,
|
||||
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||
nodeModules: webpackPaths.appNodeModulesPath,
|
||||
}),
|
||||
],
|
||||
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false,
|
||||
},
|
||||
|
||||
devServer: {
|
||||
port,
|
||||
compress: true,
|
||||
hot: true,
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
static: {
|
||||
publicPath: '/',
|
||||
},
|
||||
historyApiFallback: {
|
||||
verbose: true,
|
||||
},
|
||||
setupMiddlewares(middlewares) {
|
||||
console.log('Starting preload.js builder...');
|
||||
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.on('close', (code: number) => process.exit(code!))
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
|
||||
console.log('Starting Main Process...');
|
||||
let args = ['run', 'start:main'];
|
||||
if (process.env.MAIN_ARGS) {
|
||||
args = args.concat(
|
||||
['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(),
|
||||
);
|
||||
}
|
||||
spawn('npm', args, {
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
.on('close', (code: number) => {
|
||||
preloadProcess.kill();
|
||||
process.exit(code!);
|
||||
})
|
||||
.on('error', (spawnError) => console.error(spawnError));
|
||||
return middlewares;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
141
.erb/configs/webpack.config.renderer.prod.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Build config for electron renderer process
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import webpack from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||
import { merge } from 'webpack-merge';
|
||||
import TerserPlugin from 'terser-webpack-plugin';
|
||||
import baseConfig from './webpack.config.base';
|
||||
import webpackPaths from './webpack.paths';
|
||||
import checkNodeEnv from '../scripts/check-node-env';
|
||||
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||
|
||||
checkNodeEnv('production');
|
||||
deleteSourceMaps();
|
||||
|
||||
const configuration: webpack.Configuration = {
|
||||
devtool: 'source-map',
|
||||
|
||||
mode: 'production',
|
||||
|
||||
target: ['web', 'electron-renderer'],
|
||||
|
||||
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
|
||||
|
||||
output: {
|
||||
path: webpackPaths.distRendererPath,
|
||||
publicPath: './',
|
||||
filename: 'renderer.js',
|
||||
library: {
|
||||
type: 'umd',
|
||||
},
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: true,
|
||||
sourceMap: true,
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
'sass-loader',
|
||||
],
|
||||
include: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
{
|
||||
test: /\.s?(a|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||
exclude: /\.module\.s?(c|a)ss$/,
|
||||
},
|
||||
// Fonts
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// Images
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// SVG
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
prettier: false,
|
||||
svgo: false,
|
||||
svgoConfig: {
|
||||
plugins: [{ removeViewBox: false }],
|
||||
},
|
||||
titleProp: true,
|
||||
ref: true,
|
||||
},
|
||||
},
|
||||
'file-loader',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
/**
|
||||
* Create global constants which can be configured at compile time.
|
||||
*
|
||||
* Useful for allowing different behaviour between development builds and
|
||||
* release builds
|
||||
*
|
||||
* NODE_ENV should be production so that modules do not perform certain
|
||||
* development checks
|
||||
*/
|
||||
new webpack.EnvironmentPlugin({
|
||||
NODE_ENV: 'production',
|
||||
DEBUG_PROD: false,
|
||||
}),
|
||||
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'style.css',
|
||||
}),
|
||||
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||
analyzerPort: 8889,
|
||||
}),
|
||||
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true,
|
||||
},
|
||||
isBrowser: false,
|
||||
isDevelopment: false,
|
||||
}),
|
||||
|
||||
new webpack.DefinePlugin({
|
||||
'process.type': '"renderer"',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default merge(baseConfig, configuration);
|
||||
42
.erb/configs/webpack.paths.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const path = require('path');
|
||||
|
||||
const rootPath = path.join(__dirname, '../..');
|
||||
|
||||
const erbPath = path.join(__dirname, '..');
|
||||
const erbNodeModulesPath = path.join(erbPath, 'node_modules');
|
||||
|
||||
const dllPath = path.join(__dirname, '../dll');
|
||||
|
||||
const srcPath = path.join(rootPath, 'src');
|
||||
const srcMainPath = path.join(srcPath, 'main');
|
||||
const srcRendererPath = path.join(srcPath, 'renderer');
|
||||
|
||||
const releasePath = path.join(rootPath, 'release');
|
||||
const appPath = path.join(releasePath, 'app');
|
||||
const appPackagePath = path.join(appPath, 'package.json');
|
||||
const appNodeModulesPath = path.join(appPath, 'node_modules');
|
||||
const srcNodeModulesPath = path.join(srcPath, 'node_modules');
|
||||
|
||||
const distPath = path.join(appPath, 'dist');
|
||||
const distMainPath = path.join(distPath, 'main');
|
||||
const distRendererPath = path.join(distPath, 'renderer');
|
||||
|
||||
const buildPath = path.join(releasePath, 'build');
|
||||
|
||||
export default {
|
||||
rootPath,
|
||||
erbNodeModulesPath,
|
||||
dllPath,
|
||||
srcPath,
|
||||
srcMainPath,
|
||||
srcRendererPath,
|
||||
releasePath,
|
||||
appPath,
|
||||
appPackagePath,
|
||||
appNodeModulesPath,
|
||||
srcNodeModulesPath,
|
||||
distPath,
|
||||
distMainPath,
|
||||
distRendererPath,
|
||||
buildPath,
|
||||
};
|
||||
32
.erb/img/erb-banner.svg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
.erb/img/erb-logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
6
.erb/img/palette-sponsor-banner.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
.erb/mocks/fileMock.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'test-file-stub';
|
||||
8
.erb/scripts/.eslintrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"global-require": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"import/no-extraneous-dependencies": "off"
|
||||
}
|
||||
}
|
||||
34
.erb/scripts/check-build-exists.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Check if the renderer and main bundles are built
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import { TextEncoder, TextDecoder } from 'node:util';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
|
||||
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
|
||||
|
||||
if (!fs.existsSync(mainPath)) {
|
||||
throw new Error(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
'The main process is not built yet. Build it by running "npm run build:main"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(rendererPath)) {
|
||||
throw new Error(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// JSDOM does not implement TextEncoder and TextDecoder
|
||||
if (!global.TextEncoder) {
|
||||
global.TextEncoder = TextEncoder;
|
||||
}
|
||||
if (!global.TextDecoder) {
|
||||
// @ts-ignore
|
||||
global.TextDecoder = TextDecoder;
|
||||
}
|
||||
54
.erb/scripts/check-native-dep.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import fs from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import { execSync } from 'child_process';
|
||||
import { dependencies } from '../../package.json';
|
||||
|
||||
if (dependencies) {
|
||||
const dependenciesKeys = Object.keys(dependencies);
|
||||
const nativeDeps = fs
|
||||
.readdirSync('node_modules')
|
||||
.filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`));
|
||||
if (nativeDeps.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
try {
|
||||
// Find the reason for why the dependency is installed. If it is installed
|
||||
// because of a devDependency then that is okay. Warn when it is installed
|
||||
// because of a dependency
|
||||
const { dependencies: dependenciesObject } = JSON.parse(
|
||||
execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(),
|
||||
);
|
||||
const rootDependencies = Object.keys(dependenciesObject);
|
||||
const filteredRootDependencies = rootDependencies.filter((rootDependency) =>
|
||||
dependenciesKeys.includes(rootDependency),
|
||||
);
|
||||
if (filteredRootDependencies.length > 0) {
|
||||
const plural = filteredRootDependencies.length > 1;
|
||||
console.log(`
|
||||
${chalk.whiteBright.bgYellow.bold(
|
||||
'Webpack does not work with native dependencies.',
|
||||
)}
|
||||
${chalk.bold(filteredRootDependencies.join(', '))} ${
|
||||
plural ? 'are native dependencies' : 'is a native dependency'
|
||||
} and should be installed inside of the "./release/app" folder.
|
||||
First, uninstall the packages from "./package.json":
|
||||
${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')}
|
||||
${chalk.bold(
|
||||
'Then, instead of installing the package to the root "./package.json":',
|
||||
)}
|
||||
${chalk.whiteBright.bgRed.bold('npm install your-package')}
|
||||
${chalk.bold('Install the package to "./release/app/package.json"')}
|
||||
${chalk.whiteBright.bgGreen.bold(
|
||||
'cd ./release/app && npm install your-package',
|
||||
)}
|
||||
Read more about native dependencies at:
|
||||
${chalk.bold(
|
||||
'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure',
|
||||
)}
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.log('Native dependencies could not be checked');
|
||||
}
|
||||
}
|
||||
16
.erb/scripts/check-node-env.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
export default function checkNodeEnv(expectedEnv) {
|
||||
if (!expectedEnv) {
|
||||
throw new Error('"expectedEnv" not set');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== expectedEnv) {
|
||||
console.log(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
`"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`,
|
||||
),
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
16
.erb/scripts/check-port-in-use.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import chalk from 'chalk';
|
||||
import detectPort from 'detect-port';
|
||||
|
||||
const port = process.env.PORT || '1212';
|
||||
|
||||
detectPort(port, (_err, availablePort) => {
|
||||
if (port !== String(availablePort)) {
|
||||
throw new Error(
|
||||
chalk.whiteBright.bgRed.bold(
|
||||
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
19
.erb/scripts/clean.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const rimraf = require('rimraf');
|
||||
const fs = require('fs');
|
||||
const webpackPaths = require('../configs/webpack.paths').default;
|
||||
|
||||
const foldersToRemove = [
|
||||
webpackPaths.distPath,
|
||||
webpackPaths.buildPath,
|
||||
webpackPaths.dllPath,
|
||||
];
|
||||
|
||||
foldersToRemove.forEach((folder) => {
|
||||
if (fs.existsSync(folder)) {
|
||||
try {
|
||||
rimraf.sync(folder);
|
||||
} catch (error) {
|
||||
console.log(`Warning: Could not remove ${folder}:`, error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
17
.erb/scripts/delete-source-maps.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const webpackPaths = require('../configs/webpack.paths').default;
|
||||
|
||||
function deleteSourceMaps() {
|
||||
if (fs.existsSync(webpackPaths.distMainPath))
|
||||
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'), {
|
||||
glob: true,
|
||||
});
|
||||
if (fs.existsSync(webpackPaths.distRendererPath))
|
||||
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'), {
|
||||
glob: true,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = deleteSourceMaps;
|
||||
20
.erb/scripts/electron-rebuild.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import { dependencies } from '../../release/app/package.json';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
if (
|
||||
Object.keys(dependencies || {}).length > 0 &&
|
||||
fs.existsSync(webpackPaths.appNodeModulesPath)
|
||||
) {
|
||||
const electronRebuildCmd =
|
||||
'../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .';
|
||||
const cmd =
|
||||
process.platform === 'win32'
|
||||
? electronRebuildCmd.replace(/\//g, '\\')
|
||||
: electronRebuildCmd;
|
||||
execSync(cmd, {
|
||||
cwd: webpackPaths.appPath,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
14
.erb/scripts/link-modules.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import fs from 'fs';
|
||||
import webpackPaths from '../configs/webpack.paths';
|
||||
|
||||
const { srcNodeModulesPath, appNodeModulesPath, erbNodeModulesPath } =
|
||||
webpackPaths;
|
||||
|
||||
if (fs.existsSync(appNodeModulesPath)) {
|
||||
if (!fs.existsSync(srcNodeModulesPath)) {
|
||||
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
|
||||
}
|
||||
if (!fs.existsSync(erbNodeModulesPath)) {
|
||||
fs.symlinkSync(appNodeModulesPath, erbNodeModulesPath, 'junction');
|
||||
}
|
||||
}
|
||||
38
.erb/scripts/notarize.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { notarize } = require('@electron/notarize');
|
||||
const { build } = require('../../package.json');
|
||||
|
||||
exports.default = async function notarizeMacos(context) {
|
||||
const { electronPlatformName, appOutDir } = context;
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CI !== 'true') {
|
||||
console.warn('Skipping notarizing step. Packaging is not running in CI');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
'APPLE_ID' in process.env &&
|
||||
'APPLE_ID_PASS' in process.env &&
|
||||
'APPLE_TEAM_ID' in process.env
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
'Skipping notarizing step. APPLE_ID, APPLE_ID_PASS, and APPLE_TEAM_ID env variables must be set',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
|
||||
await notarize({
|
||||
tool: 'notarytool',
|
||||
appBundleId: build.appId,
|
||||
appPath: `${appOutDir}/${appName}.app`,
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_ID_PASS,
|
||||
teamId: process.env.APPLE_TEAM_ID,
|
||||
});
|
||||
};
|
||||
33
.eslintignore
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
.eslintcache
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
release/app/dist
|
||||
release/build
|
||||
.erb/dll
|
||||
|
||||
.idea
|
||||
npm-debug.log.*
|
||||
*.css.d.ts
|
||||
*.sass.d.ts
|
||||
*.scss.d.ts
|
||||
|
||||
# eslint ignores hidden directories by default:
|
||||
# https://github.com/eslint/eslint/issues/8429
|
||||
!.erb
|
||||
37
.eslintrc.js
Normal file
@@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
extends: 'erb',
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
// A temporary hack related to IDE not resolving correct package.json
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-filename-extension': 'off',
|
||||
'import/extensions': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-import-module-exports': 'off',
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
moduleDirectory: ['node_modules', 'src/'],
|
||||
},
|
||||
webpack: {
|
||||
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
};
|
||||
12
.gitattributes
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
* text eol=lf
|
||||
*.exe binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [electron-react-boilerplate, amilajack]
|
||||
patreon: amilajack
|
||||
open_collective: electron-react-boilerplate-594
|
||||
67
.github/ISSUE_TEMPLATE/1-Bug_report.md
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: You're having technical issues. 🐞
|
||||
labels: 'bug'
|
||||
---
|
||||
|
||||
<!-- Please use the following issue template or your issue will be closed -->
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<!-- If the following boxes are not ALL checked, your issue is likely to be closed -->
|
||||
|
||||
- [ ] Using npm
|
||||
- [ ] Using an up-to-date [`main` branch](https://github.com/electron-react-boilerplate/electron-react-boilerplate/tree/main)
|
||||
- [ ] Using latest version of devtools. [Check the docs for how to update](https://electron-react-boilerplate.js.org/docs/dev-tools/)
|
||||
- [ ] Tried solutions mentioned in [#400](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/400)
|
||||
- [ ] For issue in production release, add devtools output of `DEBUG_PROD=true npm run build && npm start`
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- What should have happened? -->
|
||||
|
||||
## Current Behavior
|
||||
|
||||
<!--- What went wrong? -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!-- Add relevant code and/or a live example -->
|
||||
<!-- Add stack traces -->
|
||||
|
||||
1.
|
||||
|
||||
2.
|
||||
|
||||
3.
|
||||
|
||||
4.
|
||||
|
||||
## Possible Solution (Not obligatory)
|
||||
|
||||
<!--- Suggest a reason for the bug or how to fix it. -->
|
||||
|
||||
## Context
|
||||
|
||||
<!--- How has this issue affected you? What are you trying to accomplish? -->
|
||||
<!--- Did you make any changes to the boilerplate after cloning it? -->
|
||||
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
|
||||
- Node version :
|
||||
- electron-react-boilerplate version or branch :
|
||||
- Operating System and version :
|
||||
- Link to your project :
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
19
.github/ISSUE_TEMPLATE/2-Question.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask a question.❓
|
||||
labels: 'question'
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What do you need help with? -->
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
15
.github/ISSUE_TEMPLATE/3-Feature_request.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: You want something added to the boilerplate. 🎉
|
||||
labels: 'enhancement'
|
||||
---
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
6
.github/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
requiredHeaders:
|
||||
- Prerequisites
|
||||
- Expected Behavior
|
||||
- Current Behavior
|
||||
- Possible Solution
|
||||
- Your Environment
|
||||
17
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- discussion
|
||||
- security
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '44 16 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
47
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# To enable auto publishing to github, update your electron publisher
|
||||
# config in package.json > "build" and remove the conditional below
|
||||
if: ${{ github.repository_owner == 'electron-react-boilerplate' }}
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node and NPM
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install and build
|
||||
run: |
|
||||
npm install
|
||||
npm run postinstall
|
||||
npm run build
|
||||
|
||||
- name: Publish releases
|
||||
env:
|
||||
# The APPLE_* values are used for auto updates signing
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASS }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
# This is used for uploading release assets to github
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm exec electron-builder -- --publish always --win --mac --linux
|
||||
34
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js and NPM
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: npm install
|
||||
run: |
|
||||
npm install
|
||||
|
||||
- name: npm test
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm run package
|
||||
npm run lint
|
||||
npm exec tsc
|
||||
npm test
|
||||
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
.eslintcache
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
release/app/dist
|
||||
release/build
|
||||
.erb/dll
|
||||
|
||||
.idea
|
||||
npm-debug.log.*
|
||||
*.css.d.ts
|
||||
*.sass.d.ts
|
||||
*.scss.d.ts
|
||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"]
|
||||
}
|
||||
30
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Electron: Main",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"protocol": "inspector",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start"],
|
||||
"env": {
|
||||
"MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Electron: Renderer",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 9223,
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"timeout": 15000
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Electron: All",
|
||||
"configurations": ["Electron: Main", "Electron: Renderer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
30
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"files.associations": {
|
||||
".eslintrc": "jsonc",
|
||||
".prettierrc": "jsonc",
|
||||
".eslintignore": "ignore"
|
||||
},
|
||||
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"html",
|
||||
"typescriptreact"
|
||||
],
|
||||
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
|
||||
"search.exclude": {
|
||||
".git": true,
|
||||
".eslintcache": true,
|
||||
".erb/dll": true,
|
||||
"release/{build,app/dist}": true,
|
||||
"node_modules": true,
|
||||
"npm-debug.log.*": true,
|
||||
"test/**/__snapshots__": true,
|
||||
"package-lock.json": true,
|
||||
"*.{css,sass,scss}.d.ts": true
|
||||
}
|
||||
}
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present Electron React Boilerplate
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
35
assets/assets.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
type Styles = Record<string, string>;
|
||||
|
||||
declare module '*.svg' {
|
||||
import React = require('react');
|
||||
|
||||
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.sass' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
8
assets/entitlements.mac.plist
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
assets/icon.icns
Normal file
BIN
assets/icon.ico
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
48
assets/icon.svg
Normal file
@@ -0,0 +1,48 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="128" cy="128" r="120" fill="#326CE5" stroke="#ffffff" stroke-width="8"/>
|
||||
|
||||
<!-- Kubernetes wheel -->
|
||||
<g transform="translate(128,128)">
|
||||
<!-- Central hub -->
|
||||
<circle cx="0" cy="0" r="20" fill="#ffffff"/>
|
||||
|
||||
<!-- Spokes -->
|
||||
<g stroke="#ffffff" stroke-width="6" stroke-linecap="round">
|
||||
<!-- Top spoke -->
|
||||
<line x1="0" y1="-20" x2="0" y2="-80"/>
|
||||
<circle cx="0" cy="-80" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Top-right spoke -->
|
||||
<line x1="14.14" y1="-14.14" x2="56.57" y2="-56.57"/>
|
||||
<circle cx="56.57" cy="-56.57" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Right spoke -->
|
||||
<line x1="20" y1="0" x2="80" y2="0"/>
|
||||
<circle cx="80" cy="0" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Bottom-right spoke -->
|
||||
<line x1="14.14" y1="14.14" x2="56.57" y2="56.57"/>
|
||||
<circle cx="56.57" cy="56.57" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Bottom spoke -->
|
||||
<line x1="0" y1="20" x2="0" y2="80"/>
|
||||
<circle cx="0" cy="80" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Bottom-left spoke -->
|
||||
<line x1="-14.14" y1="14.14" x2="-56.57" y2="56.57"/>
|
||||
<circle cx="-56.57" cy="56.57" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Left spoke -->
|
||||
<line x1="-20" y1="0" x2="-80" y2="0"/>
|
||||
<circle cx="-80" cy="0" r="8" fill="#ffffff"/>
|
||||
|
||||
<!-- Top-left spoke -->
|
||||
<line x1="-14.14" y1="-14.14" x2="-56.57" y2="-56.57"/>
|
||||
<circle cx="-56.57" cy="-56.57" r="8" fill="#ffffff"/>
|
||||
</g>
|
||||
|
||||
<!-- NOW text at bottom -->
|
||||
<text x="0" y="110" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="24" font-weight="bold">NOW</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icons/1024x1024.png
Executable file
|
After Width: | Height: | Size: 156 KiB |
BIN
assets/icons/128x128.png
Executable file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/icons/16x16.png
Executable file
|
After Width: | Height: | Size: 954 B |
BIN
assets/icons/24x24.png
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/icons/256x256.png
Executable file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/icons/32x32.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/icons/48x48.png
Executable file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/icons/512x512.png
Executable file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/icons/64x64.png
Executable file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
assets/icons/96x96.png
Executable file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/logo.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
18
assets/logo.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Created with Vectornator (http://vectornator.io/) -->
|
||||
<svg height="680.0px" stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="0 0 2520 680" width="2520.0px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs/>
|
||||
<g id="Camada-1">
|
||||
<text fill="#326ce5" font-family="BanglaMN-Bold" font-size="330.214" opacity="1" stroke="none" text-anchor="start" transform="matrix(1 0 0 1 678.969 102.267)" x="0" y="0">
|
||||
<tspan textLength="965.875" x="0" y="348"><![CDATA[Kube]]></tspan>
|
||||
</text>
|
||||
<text fill="#64748b" font-family="BanglaMN-Bold" font-size="329.987" opacity="1" stroke="none" text-anchor="start" transform="matrix(1 0 0 1 1655.01 102.267)" x="0" y="0">
|
||||
<tspan textLength="830.466" x="0" y="347"><![CDATA[Now]]></tspan>
|
||||
</text>
|
||||
<path d="M417.649 46.093L390.409 73.2923L608.877 291.76L327.434 573.201L353.798 599.606L638.657 383.522L636.075 264.759L636.273 264.521L417.649 46.093Z" fill="#326ce5" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||
<path d="M376.194 87.5469L349.709 114.032L515.922 280.205L294.199 453.724L348.597 511.457L574.331 285.724L548.124 259.518L545.584 256.976L376.194 87.5469Z" fill="#326ce5" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||
<path d="M406.053 34.4986L259.138 38.8663L20.8171 349.574L44.6809 373.397L55.9577 384.675L312.345 641.061L343.712 609.692L87.2864 353.264L406.053 34.4986Z" fill="#64748b" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||
<path d="M334.7 129.041L108.967 354.775L135.173 380.982L137.714 383.522L307.143 552.95L333.588 526.466L167.455 360.332L390.409 186.774L334.7 129.041Z" fill="#64748b" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
18
components.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
23495
package-lock.json
generated
Normal file
293
package.json
Normal file
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"description": "A foundation for scalable desktop apps",
|
||||
"keywords": [
|
||||
"electron",
|
||||
"boilerplate",
|
||||
"react",
|
||||
"typescript",
|
||||
"ts",
|
||||
"sass",
|
||||
"webpack",
|
||||
"hot",
|
||||
"reload"
|
||||
],
|
||||
"homepage": "https://github.com/electron-react-boilerplate/electron-react-boilerplate#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Electron React Boilerplate Maintainers",
|
||||
"email": "electronreactboilerplate@gmail.com",
|
||||
"url": "https://electron-react-boilerplate.js.org"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Amila Welihinda",
|
||||
"email": "amilajack@gmail.com",
|
||||
"url": "https://github.com/amilajack"
|
||||
}
|
||||
],
|
||||
"main": "./.erb/dll/main.bundle.dev.js",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
"build:dev": "concurrently \"npm run build:main:dev\" \"npm run build:renderer:dev\"",
|
||||
"build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
"build:main:dev": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
|
||||
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
|
||||
"build:renderer:dev": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
|
||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
|
||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build:dev && electron-builder build --publish never",
|
||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
|
||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
|
||||
"start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --",
|
||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends browserslist-config-erb"
|
||||
],
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
".prettierrc",
|
||||
".eslintrc"
|
||||
],
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleDirectories": [
|
||||
"node_modules",
|
||||
"release/app/node_modules",
|
||||
"src"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx",
|
||||
"json"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
|
||||
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
|
||||
},
|
||||
"setupFiles": [
|
||||
"./.erb/scripts/check-build-exists.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost/"
|
||||
},
|
||||
"testPathIgnorePatterns": [
|
||||
"release/app/dist",
|
||||
".erb/dll"
|
||||
],
|
||||
"transform": {
|
||||
"\\.(ts|tsx|js|jsx)$": "ts-jest"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/notarize": "^3.0.0",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-debug": "^4.1.0",
|
||||
"electron-log": "^5.3.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"recharts": "^3.3.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "22.13.10",
|
||||
"@types/react": "^19.0.11",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"browserslist-config-erb": "^0.0.3",
|
||||
"chalk": "^4.1.2",
|
||||
"concurrently": "^9.1.2",
|
||||
"core-js": "^3.41.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
"detect-port": "^2.1.0",
|
||||
"electron": "^35.0.2",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electronmon": "^2.0.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-erb": "^4.1.0",
|
||||
"eslint-import-resolver-typescript": "^4.1.1",
|
||||
"eslint-import-resolver-webpack": "^0.13.10",
|
||||
"eslint-plugin-compat": "^6.0.2",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest": "^28.11.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prettier": "^3.5.3",
|
||||
"react-refresh": "^0.16.0",
|
||||
"react-test-renderer": "^19.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"sass": "^1.86.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"ts-jest": "^29.2.6",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.2.0",
|
||||
"typescript": "^5.8.2",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.0",
|
||||
"webpack-merge": "^6.0.1"
|
||||
},
|
||||
"build": {
|
||||
"productName": "Kube Now",
|
||||
"appId": "org.erb.KubeNow",
|
||||
"asar": true,
|
||||
"asarUnpack": "**\\*.{node,dll}",
|
||||
"files": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"notarize": false,
|
||||
"target": {
|
||||
"target": "default",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"type": "distribution",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "assets/entitlements.mac.plist",
|
||||
"entitlementsInherit": "assets/entitlements.mac.plist",
|
||||
"gatekeeperAssess": false
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"category": "Development"
|
||||
},
|
||||
"directories": {
|
||||
"app": "release/app",
|
||||
"buildResources": "assets",
|
||||
"output": "release/build"
|
||||
},
|
||||
"extraResources": [
|
||||
"./assets/**"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "electron-react-boilerplate",
|
||||
"repo": "electron-react-boilerplate"
|
||||
}
|
||||
},
|
||||
"collective": {
|
||||
"url": "https://opencollective.com/electron-react-boilerplate-594"
|
||||
},
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": ">=14.x",
|
||||
"onFail": "error"
|
||||
},
|
||||
"packageManager": {
|
||||
"name": "npm",
|
||||
"version": ">=7.x",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
"electronmon": {
|
||||
"patterns": [
|
||||
"!**/**",
|
||||
"src/main/**",
|
||||
".erb/dll/**"
|
||||
],
|
||||
"logLevel": "quiet"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
14
release/app/package-lock.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
release/app/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"description": "A foundation for scalable desktop apps",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Electron React Boilerplate Maintainers",
|
||||
"email": "electronreactboilerplate@gmail.com",
|
||||
"url": "https://github.com/electron-react-boilerplate"
|
||||
},
|
||||
"main": "./dist/main/main.js",
|
||||
"scripts": {
|
||||
"rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
|
||||
"postinstall": "npm run rebuild && npm run link-modules",
|
||||
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
4
release/app/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
9
src/__tests__/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import App from '../renderer/App';
|
||||
|
||||
describe('App', () => {
|
||||
it('should render', () => {
|
||||
expect(render(<App />)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
21
src/components/layout/main-layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { ToastProvider } from '@/hooks/useToast';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
530
src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Server,
|
||||
Clock,
|
||||
Shield,
|
||||
Rocket,
|
||||
Briefcase,
|
||||
Database,
|
||||
Container,
|
||||
Network,
|
||||
Globe,
|
||||
GitBranch,
|
||||
HardDrive,
|
||||
Map,
|
||||
Key,
|
||||
Layers,
|
||||
Boxes,
|
||||
FolderTree,
|
||||
FileCode,
|
||||
Monitor,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useKubeconfigs } from '@/hooks/useKubeconfigs';
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [clusterExpanded, setClusterExpanded] = useState(false);
|
||||
const [workloadsExpanded, setWorkloadsExpanded] = useState(false);
|
||||
const [serviceDiscoveryExpanded, setServiceDiscoveryExpanded] =
|
||||
useState(false);
|
||||
const [storageExpanded, setStorageExpanded] = useState(false);
|
||||
|
||||
// Kubeconfig management
|
||||
const { kubeconfigs, activeConfig, setActiveKubeconfig, forceReload } =
|
||||
useKubeconfigs();
|
||||
|
||||
// Garantir que dados estão carregados quando sidebar monta
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'🔍 Sidebar mounted - kubeconfigs length:',
|
||||
kubeconfigs.length,
|
||||
'activeConfig:',
|
||||
activeConfig?.name,
|
||||
);
|
||||
if (kubeconfigs.length === 0) {
|
||||
console.log('🔄 Sidebar: No kubeconfigs found, forcing reload');
|
||||
forceReload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debug: log quando kubeconfigs mudam
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'📝 Sidebar: kubeconfigs updated:',
|
||||
kubeconfigs.length,
|
||||
kubeconfigs.map((c) => c.name),
|
||||
);
|
||||
}, [kubeconfigs]);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: Home,
|
||||
label: 'Dashboard',
|
||||
path: '/',
|
||||
active: location.pathname === '/',
|
||||
},
|
||||
];
|
||||
|
||||
const clusterItems = [
|
||||
{ icon: Boxes, label: 'Nodes', path: '/cluster/nodes' },
|
||||
{ icon: FolderTree, label: 'Namespaces', path: '/cluster/namespaces' },
|
||||
];
|
||||
|
||||
const workloadItems = [
|
||||
{ icon: Clock, label: 'CronJobs', path: '/workloads/cronjobs' },
|
||||
{ icon: Shield, label: 'DaemonSets', path: '/workloads/daemonsets' },
|
||||
{ icon: Rocket, label: 'Deployments', path: '/workloads/deployments' },
|
||||
{ icon: Activity, label: 'HPA', path: '/workloads/hpa' },
|
||||
{ icon: Briefcase, label: 'Jobs', path: '/workloads/jobs' },
|
||||
{ icon: Container, label: 'Pods', path: '/workloads/pods' },
|
||||
{ icon: Database, label: 'StatefulSets', path: '/workloads/statefulsets' },
|
||||
];
|
||||
|
||||
const serviceDiscoveryItems = [
|
||||
{ icon: Globe, label: 'Ingresses', path: '/service-discovery/ingresses' },
|
||||
{ icon: GitBranch, label: 'Services', path: '/service-discovery/services' },
|
||||
];
|
||||
|
||||
const storageItems = [
|
||||
{ icon: Map, label: 'ConfigMaps', path: '/storage/configmaps' },
|
||||
{ icon: Key, label: 'Secrets', path: '/storage/secrets' },
|
||||
];
|
||||
|
||||
const sidebarStyle = {
|
||||
backgroundColor: activeConfig?.color || '#3b82f6',
|
||||
borderRight: `3px solid ${activeConfig?.color || '#3b82f6'}`,
|
||||
};
|
||||
|
||||
// Função para determinar se a cor é clara ou escura
|
||||
const isLightColor = (color: string) => {
|
||||
const hex = color.replace('#', '');
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return brightness > 128;
|
||||
};
|
||||
|
||||
const isLight = isLightColor(activeConfig?.color || '#3b82f6');
|
||||
const textColor = isLight ? '#1f2937' : '#ffffff';
|
||||
const mutedTextColor = isLight ? '#6b7280' : '#d1d5db';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-r w-64" style={sidebarStyle}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center py-4 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
<span className="text-2xl font-bold" style={{ color: textColor }}>
|
||||
Kube Now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-px w-full"
|
||||
style={{ backgroundColor: isLight ? '#e5e7eb' : '#374151' }}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-2">
|
||||
<ul className="space-y-1">
|
||||
{menuItems.map((item, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
variant={item.active ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start px-3"
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
backgroundColor: item.active
|
||||
? isLight
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: 'rgba(255,255,255,0.1)'
|
||||
: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<item.icon
|
||||
className="h-5 w-5 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Cluster Menu */}
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => setClusterExpanded(!clusterExpanded)}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Layers className="h-5 w-5 mr-3" style={{ color: textColor }} />
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
Cluster
|
||||
</span>
|
||||
</div>
|
||||
{clusterExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" style={{ color: textColor }} />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" style={{ color: textColor }} />
|
||||
)}
|
||||
</Button>
|
||||
{/* Submenu */}
|
||||
{clusterExpanded && (
|
||||
<ul className="ml-6 mt-1 space-y-1">
|
||||
{clusterItems.map((item, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
variant={
|
||||
location.pathname === item.path ? 'secondary' : 'ghost'
|
||||
}
|
||||
className="w-full justify-start px-3"
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
location.pathname === item.path
|
||||
? isLight
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: 'rgba(255,255,255,0.1)'
|
||||
: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<item.icon
|
||||
className="h-4 w-4 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{/* Workloads Menu */}
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => setWorkloadsExpanded(!workloadsExpanded)}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 mr-3" style={{ color: textColor }} />
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
Workloads
|
||||
</span>
|
||||
</div>
|
||||
{workloadsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" style={{ color: textColor }} />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" style={{ color: textColor }} />
|
||||
)}
|
||||
</Button>
|
||||
{/* Submenu */}
|
||||
{workloadsExpanded && (
|
||||
<ul className="ml-6 mt-1 space-y-1">
|
||||
{workloadItems.map((item, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
variant={
|
||||
location.pathname === item.path ? 'secondary' : 'ghost'
|
||||
}
|
||||
className="w-full justify-start px-3"
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
location.pathname === item.path
|
||||
? isLight
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: 'rgba(255,255,255,0.1)'
|
||||
: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<item.icon
|
||||
className="h-4 w-4 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{/* Service Discovery Menu */}
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() =>
|
||||
setServiceDiscoveryExpanded(!serviceDiscoveryExpanded)
|
||||
}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Network
|
||||
className="h-5 w-5 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
Service Discovery
|
||||
</span>
|
||||
</div>
|
||||
{serviceDiscoveryExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" style={{ color: textColor }} />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" style={{ color: textColor }} />
|
||||
)}
|
||||
</Button>
|
||||
{/* Submenu */}
|
||||
{serviceDiscoveryExpanded && (
|
||||
<ul className="ml-6 mt-1 space-y-1">
|
||||
{serviceDiscoveryItems.map((item, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
variant={
|
||||
location.pathname === item.path ? 'secondary' : 'ghost'
|
||||
}
|
||||
className="w-full justify-start px-3"
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
location.pathname === item.path
|
||||
? isLight
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: 'rgba(255,255,255,0.1)'
|
||||
: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<item.icon
|
||||
className="h-4 w-4 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{/* Storage Menu */}
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => setStorageExpanded(!storageExpanded)}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<HardDrive
|
||||
className="h-5 w-5 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
Storage
|
||||
</span>
|
||||
</div>
|
||||
{storageExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" style={{ color: textColor }} />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" style={{ color: textColor }} />
|
||||
)}
|
||||
</Button>
|
||||
{/* Submenu */}
|
||||
{storageExpanded && (
|
||||
<ul className="ml-6 mt-1 space-y-1">
|
||||
{storageItems.map((item, index) => (
|
||||
<li key={index}>
|
||||
<Button
|
||||
variant={
|
||||
location.pathname === item.path ? 'secondary' : 'ghost'
|
||||
}
|
||||
className="w-full justify-start px-3"
|
||||
onClick={() => navigate(item.path)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
location.pathname === item.path
|
||||
? isLight
|
||||
? 'rgba(0,0,0,0.1)'
|
||||
: 'rgba(255,255,255,0.1)'
|
||||
: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<item.icon
|
||||
className="h-4 w-4 mr-3"
|
||||
style={{ color: textColor }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: textColor }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer - Kubeconfig Selector */}
|
||||
<div
|
||||
className="p-4"
|
||||
style={{
|
||||
borderTop: `1px solid ${isLight ? '#e5e7eb' : '#374151'}`,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: textColor,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-3 w-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: `${activeConfig?.color || '#3b82f6'}20`,
|
||||
color: activeConfig?.color || '#3b82f6',
|
||||
}}
|
||||
>
|
||||
<FileCode className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{activeConfig?.name || 'Kubeconfig Padrão'}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs truncate"
|
||||
style={{ color: mutedTextColor }}
|
||||
>
|
||||
Configuração do cluster
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className="h-4 w-4"
|
||||
style={{ color: mutedTextColor }}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="flex items-center">
|
||||
<Monitor className="h-4 w-4 mr-2" />
|
||||
Selecionar Kubeconfig
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{kubeconfigs.map((config) => (
|
||||
<DropdownMenuItem
|
||||
key={config.id}
|
||||
onClick={() => setActiveKubeconfig(config.id)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileCode className="h-4 w-4 mr-2" />
|
||||
<div>
|
||||
<div className="font-medium">{config.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{config.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{config.isActive && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: config.color || '#3b82f6' }}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/settings/kubeconfigs')}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Gerenciar Configurações
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
415
src/components/pages/configmap-create.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Map,
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
FileText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function ConfigMapCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
// Se o namespace global é 'all', use 'default', senão use o namespace global
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [keyValuePairs, setKeyValuePairs] = useState<KeyValuePair[]>([
|
||||
{ key: '', value: '' },
|
||||
]);
|
||||
const [labels, setLabels] = useState<KeyValuePair[]>([
|
||||
{ key: '', value: '' },
|
||||
]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const addKeyValuePair = () => {
|
||||
setKeyValuePairs([...keyValuePairs, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const removeKeyValuePair = (index: number) => {
|
||||
if (keyValuePairs.length > 1) {
|
||||
setKeyValuePairs(keyValuePairs.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateKeyValuePair = (
|
||||
index: number,
|
||||
field: 'key' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const updated = [...keyValuePairs];
|
||||
updated[index][field] = value;
|
||||
setKeyValuePairs(updated);
|
||||
};
|
||||
|
||||
const addLabel = () => {
|
||||
setLabels([...labels, { key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const removeLabel = (index: number) => {
|
||||
if (labels.length > 1) {
|
||||
setLabels(labels.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateLabel = (
|
||||
index: number,
|
||||
field: 'key' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const updated = [...labels];
|
||||
updated[index][field] = value;
|
||||
setLabels(updated);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar se há pelo menos um par chave-valor com dados
|
||||
const validPairs = keyValuePairs.filter(
|
||||
(pair) => pair.key.trim() && pair.value.trim(),
|
||||
);
|
||||
if (validPairs.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Pelo menos um par chave-valor é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar se não há chaves duplicadas
|
||||
const keys = validPairs.map((pair) => pair.key.trim());
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (keys.length !== uniqueKeys.size) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Chaves duplicadas não são permitidas',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Construir comando kubectl
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
// Adicionar dados
|
||||
const validPairs = keyValuePairs.filter(
|
||||
(pair) => pair.key.trim() && pair.value.trim(),
|
||||
);
|
||||
validPairs.forEach((pair) => {
|
||||
command += ` --from-literal="${pair.key.trim()}"="${pair.value.trim()}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
// Adicionar labels se houver (comando separado)
|
||||
const validLabels = labels.filter(
|
||||
(label) => label.key.trim() && label.value.trim(),
|
||||
);
|
||||
if (validLabels.length > 0) {
|
||||
const labelString = validLabels
|
||||
.map((label) => `${label.key.trim()}=${label.value.trim()}`)
|
||||
.join(' ');
|
||||
const labelCommand = `kubectl label configmap "${configMapName}" ${labelString} -n "${selectedNamespace}"`;
|
||||
const labelResult = await executeCommand(labelCommand);
|
||||
|
||||
if (!labelResult.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Aviso',
|
||||
description: `ConfigMap "${configMapName}" foi criado, mas falha ao aplicar labels: ${labelResult.error}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
// Redirecionar para a página de configmaps
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Map className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Criar ConfigMap
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Crie um ConfigMap para armazenar dados de configuração
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: my-app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Dados de Configuração
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addKeyValuePair}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{keyValuePairs.map((pair, index) => (
|
||||
<div
|
||||
key={`pair-${index}`}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={`key-${index}`}>Chave</Label>
|
||||
<Input
|
||||
id={`key-${index}`}
|
||||
placeholder="ex: database.host"
|
||||
value={pair.key}
|
||||
onChange={(e) =>
|
||||
updateKeyValuePair(index, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`value-${index}`}>Valor</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Textarea
|
||||
id={`value-${index}`}
|
||||
placeholder="ex: localhost:5432"
|
||||
value={pair.value}
|
||||
onChange={(e) =>
|
||||
updateKeyValuePair(index, 'value', e.target.value)
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
{keyValuePairs.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeKeyValuePair(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Labels */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
Labels (Opcional)
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addLabel}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{labels.map((label, index) => (
|
||||
<div
|
||||
key={`label-${index}`}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={`label-key-${index}`}>Chave</Label>
|
||||
<Input
|
||||
id={`label-key-${index}`}
|
||||
placeholder="ex: app"
|
||||
value={label.key}
|
||||
onChange={(e) => updateLabel(index, 'key', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`label-value-${index}`}>Valor</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id={`label-value-${index}`}
|
||||
placeholder="ex: my-app"
|
||||
value={label.value}
|
||||
onChange={(e) =>
|
||||
updateLabel(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
{labels.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeLabel(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={handleCreateConfigMap} disabled={creating}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapCreatePage;
|
||||
667
src/components/pages/configmap-details.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { YamlEditor } from '@/components/ui/yaml-editor';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useKubectl } from '@/hooks/useKubectl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
Loader2,
|
||||
Map,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
FileText,
|
||||
Settings,
|
||||
Copy,
|
||||
Key,
|
||||
Calendar,
|
||||
Tag,
|
||||
Database,
|
||||
Info,
|
||||
Edit3,
|
||||
Eye,
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ConfigMapData {
|
||||
name: string;
|
||||
namespace: string;
|
||||
creationTimestamp: string;
|
||||
labels: Record<string, string>;
|
||||
annotations: Record<string, string>;
|
||||
data: Record<string, string>;
|
||||
resourceVersion: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export function ConfigMapDetailsPage() {
|
||||
const { namespaceName, configMapName } = useParams<{
|
||||
namespaceName: string;
|
||||
configMapName: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [configMap, setConfigMap] = useState<ConfigMapData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showYaml, setShowYaml] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchConfigMapDetails = async () => {
|
||||
if (!namespaceName || !configMapName) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const command = `kubectl get configmap ${configMapName} -n ${namespaceName} -o json`;
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
const data = JSON.parse(result.data);
|
||||
setConfigMap({
|
||||
name: data.metadata.name,
|
||||
namespace: data.metadata.namespace,
|
||||
creationTimestamp: data.metadata.creationTimestamp,
|
||||
labels: data.metadata.labels || {},
|
||||
annotations: data.metadata.annotations || {},
|
||||
data: data.data || {},
|
||||
resourceVersion: data.metadata.resourceVersion,
|
||||
uid: data.metadata.uid,
|
||||
});
|
||||
} else {
|
||||
setError(result.error || 'Falha ao buscar detalhes do ConfigMap');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Erro inesperado';
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchYaml = () => {
|
||||
setShowYaml(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfigMap = async () => {
|
||||
if (!namespaceName || !configMapName) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const command = `kubectl delete configmap ${configMapName} -n ${namespaceName}`;
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Excluído',
|
||||
description: `ConfigMap "${configMapName}" foi excluído com sucesso`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Excluir ConfigMap',
|
||||
description: result.error || 'Falha ao excluir o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
showToast({
|
||||
title: 'Copiado',
|
||||
description: 'Conteúdo copiado para a área de transferência',
|
||||
variant: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const getAge = (creationTimestamp: string) => {
|
||||
const now = new Date();
|
||||
const created = new Date(creationTimestamp);
|
||||
const diffMs = now.getTime() - created.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m`;
|
||||
return `${diffSeconds}s`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigMapDetails();
|
||||
}, [namespaceName, configMapName]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Carregando detalhes do ConfigMap...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !configMap) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600 mb-4">
|
||||
{error || 'ConfigMap não encontrado'}
|
||||
</p>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={() => fetchConfigMapDetails()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/storage/configmaps')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Map className="h-8 w-8 mr-3 text-blue-600" />
|
||||
{configMap.name}
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
ConfigMap no namespace{' '}
|
||||
<Badge variant="outline" className="mx-1">
|
||||
{configMap.namespace}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<MoreVertical className="h-4 w-4 mr-2" />
|
||||
Ações
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Navegação</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/storage/configmaps')}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Lista de ConfigMaps
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações Gerais */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Info className="h-5 w-5 mr-2" />
|
||||
Informações Gerais
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={fetchYaml} variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
YAML
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Visualizar YAML completo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
copyToClipboard(JSON.stringify(configMap, null, 2))
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copiar dados como JSON</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setDeleteModalOpen(true)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Excluir este ConfigMap</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Nome
|
||||
</label>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-sm font-medium">{configMap.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(configMap.name)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Namespace
|
||||
</label>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<Badge variant="outline">{configMap.namespace}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(configMap.namespace)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Data Keys
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="secondary">
|
||||
{Object.keys(configMap.data).length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Criado há
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<span className="text-sm">
|
||||
{getAge(configMap.creationTimestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
UID
|
||||
</label>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-sm font-mono">{configMap.uid}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(configMap.uid)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Resource Version
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<span className="text-sm">{configMap.resourceVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels e Annotations lado a lado */}
|
||||
{(Object.keys(configMap.labels).length > 0 ||
|
||||
Object.keys(configMap.annotations).length > 0) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Labels */}
|
||||
{Object.keys(configMap.labels).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground flex items-center mb-3">
|
||||
<Tag className="h-4 w-4 mr-2" />
|
||||
Labels
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(configMap.labels).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-2 bg-muted rounded"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(`${key}=${value}`)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annotations */}
|
||||
{Object.keys(configMap.annotations).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground flex items-center mb-3">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Annotations
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(configMap.annotations).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-2 bg-muted rounded"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(`${key}=${value}`)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dados de Configuração */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Dados de Configuração
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{Object.keys(configMap.data).length} chave(s) de configuração
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.keys(configMap.data).length > 0 ? (
|
||||
<Tabs defaultValue="table" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="table">Tabela</TabsTrigger>
|
||||
<TabsTrigger value="raw">Texto</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="table" className="mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Chave</TableHead>
|
||||
<TableHead>Valor</TableHead>
|
||||
<TableHead className="w-16">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(configMap.data).map(([key, value]) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{key}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(key)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-md">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words font-mono bg-muted p-2 rounded">
|
||||
{value || '(vazio)'}
|
||||
</pre>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(value)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(configMap.data).map(([key, value]) => (
|
||||
<Card key={key}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{key}</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(value)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-sm whitespace-pre-wrap break-words font-mono bg-muted p-3 rounded max-h-96 overflow-y-auto">
|
||||
{value || '(vazio)'}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Database className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhum dado de configuração encontrado</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* YAML Modal */}
|
||||
<YamlEditor
|
||||
open={showYaml}
|
||||
onOpenChange={setShowYaml}
|
||||
resourceType="configmap"
|
||||
resourceName={configMap.name}
|
||||
namespace={configMap.namespace}
|
||||
title={`YAML do ConfigMap - ${configMap.name}`}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir ConfigMap</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tem certeza que deseja excluir o ConfigMap{' '}
|
||||
<strong>{configMap.name}</strong> do namespace{' '}
|
||||
<strong>{configMap.namespace}</strong>?
|
||||
<br />
|
||||
<br />
|
||||
Esta ação não pode ser desfeita e pode afetar pods que dependem
|
||||
dessas configurações.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfigMap}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Excluindo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
431
src/components/pages/configmap-import-env.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ParsedEnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
lineNumber: number;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConfigMapImportEnvPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [envContent, setEnvContent] = useState('');
|
||||
const [parsedVars, setParsedVars] = useState<ParsedEnvVar[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const parseEnvFile = (content: string): ParsedEnvVar[] => {
|
||||
const lines = content.split('\n');
|
||||
const vars: ParsedEnvVar[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const equalsIndex = trimmedLine.indexOf('=');
|
||||
if (equalsIndex === -1) {
|
||||
vars.push({
|
||||
key: trimmedLine,
|
||||
value: '',
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Formato inválido: faltando símbolo "="',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = trimmedLine.substring(0, equalsIndex).trim();
|
||||
const value = trimmedLine.substring(equalsIndex + 1).trim();
|
||||
|
||||
// Remove quotes if present
|
||||
const cleanValue = value.replace(/^["']|["']$/g, '');
|
||||
|
||||
if (!key) {
|
||||
vars.push({
|
||||
key: '',
|
||||
value: cleanValue,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Chave vazia',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate key format (should be valid env var name)
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
vars.push({
|
||||
key,
|
||||
value: cleanValue,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Nome de variável inválido',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
vars.push({
|
||||
key,
|
||||
value: cleanValue,
|
||||
lineNumber: index + 1,
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
return vars;
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setEnvContent(content);
|
||||
if (content.trim()) {
|
||||
const parsed = parseEnvFile(content);
|
||||
setParsedVars(parsed);
|
||||
} else {
|
||||
setParsedVars([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
handleContentChange(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validVars = parsedVars.filter((v) => v.isValid);
|
||||
if (validVars.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nenhuma variável válida encontrada',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const validVars = parsedVars.filter((v) => v.isValid);
|
||||
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
validVars.forEach((envVar) => {
|
||||
command += ` --from-literal="${envVar.key}"="${envVar.value}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado com ${validVars.length} variáveis`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validVars = parsedVars.filter((v) => v.isValid);
|
||||
const invalidVars = parsedVars.filter((v) => !v.isValid);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<FileText className="h-8 w-8 mr-3 text-green-600" />
|
||||
Importar Arquivo .env
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importe variáveis de ambiente de um arquivo .env
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Arquivo .env
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="envFile">Carregar arquivo .env</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".env,.txt"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="envContent">
|
||||
Ou cole o conteúdo do arquivo .env
|
||||
</Label>
|
||||
<Textarea
|
||||
id="envContent"
|
||||
placeholder={`# Exemplo de arquivo .env
|
||||
DATABASE_URL=postgresql://user:pass@localhost/db
|
||||
API_KEY=your-api-key-here
|
||||
DEBUG=true
|
||||
PORT=3000`}
|
||||
value={envContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedVars.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Eye className="h-5 w-5 mr-2" />
|
||||
Pré-visualização
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showPreview ? 'Ocultar' : 'Mostrar'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{validVars.length} variáveis válidas
|
||||
</span>
|
||||
</div>
|
||||
{invalidVars.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{invalidVars.length} variáveis inválidas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="space-y-4">
|
||||
{validVars.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">
|
||||
Variáveis Válidas:
|
||||
</h4>
|
||||
<div className="bg-green-50 p-3 rounded-md space-y-1">
|
||||
{validVars.map((envVar, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
<span className="text-green-800 font-medium">
|
||||
{envVar.key}
|
||||
</span>
|
||||
<span className="text-gray-600">=</span>
|
||||
<span className="text-green-700">{envVar.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidVars.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">
|
||||
Variáveis Inválidas:
|
||||
</h4>
|
||||
<div className="bg-red-50 p-3 rounded-md space-y-1">
|
||||
{invalidVars.map((envVar, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
<div className="font-mono text-red-800">
|
||||
Linha {envVar.lineNumber}: {envVar.key || '(vazio)'}
|
||||
</div>
|
||||
<div className="text-red-600 text-xs">
|
||||
{envVar.error}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConfigMap}
|
||||
disabled={creating || validVars.length === 0}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportEnvPage;
|
||||
504
src/components/pages/configmap-import-ini.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ParsedIniData {
|
||||
key: string;
|
||||
value: string;
|
||||
section: string;
|
||||
lineNumber: number;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConfigMapImportIniPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [iniContent, setIniContent] = useState('');
|
||||
const [parsedData, setParsedData] = useState<ParsedIniData[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [includeSection, setIncludeSection] = useState<'yes' | 'no'>('yes');
|
||||
|
||||
const parseIniFile = (content: string): ParsedIniData[] => {
|
||||
const lines = content.split('\n');
|
||||
const data: ParsedIniData[] = [];
|
||||
let currentSection = '';
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmedLine || trimmedLine.startsWith(';') || trimmedLine.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a section header
|
||||
if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) {
|
||||
currentSection = trimmedLine.slice(1, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse key-value pair
|
||||
const equalIndex = trimmedLine.indexOf('=');
|
||||
if (equalIndex === -1) {
|
||||
data.push({
|
||||
key: trimmedLine,
|
||||
value: '',
|
||||
section: currentSection,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Formato inválido: faltando símbolo "="',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = trimmedLine.substring(0, equalIndex).trim();
|
||||
const value = trimmedLine.substring(equalIndex + 1).trim();
|
||||
|
||||
if (!key) {
|
||||
data.push({
|
||||
key: '',
|
||||
value: value,
|
||||
section: currentSection,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Chave vazia',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the final key name
|
||||
let finalKey = key;
|
||||
if (includeSection === 'yes' && currentSection) {
|
||||
finalKey = `${currentSection}.${key}`;
|
||||
}
|
||||
|
||||
// Remove quotes from value if present
|
||||
let processedValue = value;
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
processedValue = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Validate key format (should be valid ConfigMap key)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(finalKey)) {
|
||||
data.push({
|
||||
key: finalKey,
|
||||
value: processedValue,
|
||||
section: currentSection,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Nome de chave inválido para ConfigMap',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
data.push({
|
||||
key: finalKey,
|
||||
value: processedValue,
|
||||
section: currentSection,
|
||||
lineNumber: index + 1,
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setIniContent(content);
|
||||
if (content.trim()) {
|
||||
const parsed = parseIniFile(content);
|
||||
setParsedData(parsed);
|
||||
} else {
|
||||
setParsedData([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
handleContentChange(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncludeSectionChange = (value: 'yes' | 'no') => {
|
||||
setIncludeSection(value);
|
||||
if (iniContent.trim()) {
|
||||
const parsed = parseIniFile(iniContent);
|
||||
setParsedData(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
if (validData.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nenhum dado válido encontrado',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
validData.forEach((item) => {
|
||||
command += ` --from-literal="${item.key}"="${item.value}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado com ${validData.length} entradas`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
const invalidData = parsedData.filter((d) => !d.isValid);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<FileText className="h-8 w-8 mr-3 text-indigo-600" />
|
||||
Importar Arquivo .ini
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importe configurações de um arquivo INI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="includeSection">Incluir Seção no Nome da Chave</Label>
|
||||
<Select value={includeSection} onValueChange={handleIncludeSectionChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="yes">
|
||||
Sim - database.host (section.key)
|
||||
</SelectItem>
|
||||
<SelectItem value="no">
|
||||
Não - host (apenas key)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Arquivo .ini
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="iniFile">Carregar arquivo .ini</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".ini,.cfg,.config"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="iniContent">Ou cole o conteúdo do arquivo .ini</Label>
|
||||
<Textarea
|
||||
id="iniContent"
|
||||
placeholder={`# Exemplo de arquivo .ini
|
||||
[database]
|
||||
host=localhost
|
||||
port=5432
|
||||
name=myapp
|
||||
ssl=true
|
||||
|
||||
[api]
|
||||
url=https://api.example.com
|
||||
timeout=30000
|
||||
retries=3
|
||||
|
||||
[logging]
|
||||
level=INFO
|
||||
format=JSON
|
||||
file=/var/log/app.log
|
||||
|
||||
[cache]
|
||||
enabled=true
|
||||
size=1000
|
||||
ttl=3600`}
|
||||
value={iniContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Eye className="h-5 w-5 mr-2" />
|
||||
Pré-visualização
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showPreview ? 'Ocultar' : 'Mostrar'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{validData.length} entradas válidas
|
||||
</span>
|
||||
</div>
|
||||
{invalidData.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{invalidData.length} entradas inválidas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="space-y-4">
|
||||
{validData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">
|
||||
Entradas Válidas:
|
||||
</h4>
|
||||
<div className="bg-green-50 p-3 rounded-md space-y-1 max-h-60 overflow-y-auto">
|
||||
{validData.map((item, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
<span className="text-green-800 font-medium">
|
||||
{item.key}
|
||||
</span>
|
||||
<span className="text-gray-600">=</span>
|
||||
<span className="text-green-700">{item.value}</span>
|
||||
{item.section && (
|
||||
<span className="text-gray-500 ml-2 text-xs">
|
||||
[{item.section}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">
|
||||
Entradas Inválidas:
|
||||
</h4>
|
||||
<div className="bg-red-50 p-3 rounded-md space-y-1">
|
||||
{invalidData.map((item, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
<div className="font-mono text-red-800">
|
||||
Linha {item.lineNumber}: {item.key || '(vazio)'}
|
||||
{item.section && (
|
||||
<span className="text-gray-500 ml-2 text-xs">
|
||||
[{item.section}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-red-600 text-xs">
|
||||
{item.error}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConfigMap}
|
||||
disabled={creating || validData.length === 0}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportIniPage;
|
||||
474
src/components/pages/configmap-import-json.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Code,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ParsedJsonData {
|
||||
key: string;
|
||||
value: string;
|
||||
path: string;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConfigMapImportJsonPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [jsonContent, setJsonContent] = useState('');
|
||||
const [parsedData, setParsedData] = useState<ParsedJsonData[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [flattenMode, setFlattenMode] = useState<'dot' | 'underscore'>('dot');
|
||||
|
||||
const flattenObject = (
|
||||
obj: any,
|
||||
prefix = '',
|
||||
separator = '.',
|
||||
): Record<string, any> => {
|
||||
const flattened: Record<string, any> = {};
|
||||
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const newKey = prefix ? `${prefix}${separator}${key}` : key;
|
||||
|
||||
if (
|
||||
obj[key] !== null &&
|
||||
typeof obj[key] === 'object' &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
Object.assign(flattened, flattenObject(obj[key], newKey, separator));
|
||||
} else {
|
||||
flattened[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const parseJsonFile = (content: string): ParsedJsonData[] => {
|
||||
const data: ParsedJsonData[] = [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const separator = flattenMode === 'dot' ? '.' : '_';
|
||||
const flattened = flattenObject(parsed, '', separator);
|
||||
|
||||
for (const [key, value] of Object.entries(flattened)) {
|
||||
// Convert arrays and objects to JSON strings
|
||||
let stringValue: string;
|
||||
if (Array.isArray(value)) {
|
||||
stringValue = JSON.stringify(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
stringValue = JSON.stringify(value);
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
|
||||
// Validate key format (should be valid ConfigMap key)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(key)) {
|
||||
data.push({
|
||||
key,
|
||||
value: stringValue,
|
||||
path: key,
|
||||
isValid: false,
|
||||
error: 'Nome de chave inválido para ConfigMap',
|
||||
});
|
||||
} else {
|
||||
data.push({
|
||||
key,
|
||||
value: stringValue,
|
||||
path: key,
|
||||
isValid: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
data.push({
|
||||
key: 'json-parse-error',
|
||||
value: '',
|
||||
path: '',
|
||||
isValid: false,
|
||||
error: `Erro ao parsear JSON: ${error instanceof Error ? error.message : 'Erro desconhecido'}`,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setJsonContent(content);
|
||||
if (content.trim()) {
|
||||
const parsed = parseJsonFile(content);
|
||||
setParsedData(parsed);
|
||||
} else {
|
||||
setParsedData([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
handleContentChange(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFlattenModeChange = (mode: 'dot' | 'underscore') => {
|
||||
setFlattenMode(mode);
|
||||
if (jsonContent.trim()) {
|
||||
const parsed = parseJsonFile(jsonContent);
|
||||
setParsedData(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
if (validData.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nenhum dado válido encontrado',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
validData.forEach((item) => {
|
||||
command += ` --from-literal="${item.key}"="${item.value}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado com ${validData.length} chaves`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
const invalidData = parsedData.filter((d) => !d.isValid);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Code className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Importar Arquivo JSON
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importe configurações de um arquivo JSON
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="flattenMode">Modo de Achatamento</Label>
|
||||
<Select value={flattenMode} onValueChange={handleFlattenModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dot">
|
||||
Separador por ponto (db.host)
|
||||
</SelectItem>
|
||||
<SelectItem value="underscore">
|
||||
Separador por underscore (db_host)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Arquivo JSON
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="jsonFile">Carregar arquivo JSON</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="jsonContent">Ou cole o conteúdo JSON</Label>
|
||||
<Textarea
|
||||
id="jsonContent"
|
||||
placeholder={`{
|
||||
"database": {
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"name": "myapp"
|
||||
},
|
||||
"api": {
|
||||
"url": "https://api.example.com",
|
||||
"timeout": 30
|
||||
},
|
||||
"features": ["auth", "logging"]
|
||||
}`}
|
||||
value={jsonContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Eye className="h-5 w-5 mr-2" />
|
||||
Pré-visualização
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showPreview ? 'Ocultar' : 'Mostrar'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{validData.length} chaves válidas
|
||||
</span>
|
||||
</div>
|
||||
{invalidData.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{invalidData.length} chaves inválidas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="space-y-4">
|
||||
{validData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">
|
||||
Chaves Válidas:
|
||||
</h4>
|
||||
<div className="bg-green-50 p-3 rounded-md space-y-1 max-h-60 overflow-y-auto">
|
||||
{validData.map((item, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
<span className="text-green-800 font-medium">
|
||||
{item.key}
|
||||
</span>
|
||||
<span className="text-gray-600">=</span>
|
||||
<span className="text-green-700">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">
|
||||
Chaves Inválidas:
|
||||
</h4>
|
||||
<div className="bg-red-50 p-3 rounded-md space-y-1">
|
||||
{invalidData.map((item, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
<div className="font-mono text-red-800">
|
||||
{item.key || '(erro)'}
|
||||
</div>
|
||||
<div className="text-red-600 text-xs">
|
||||
{item.error}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConfigMap}
|
||||
disabled={creating || validData.length === 0}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportJsonPage;
|
||||
476
src/components/pages/configmap-import-properties.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Database,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ParsedProperty {
|
||||
key: string;
|
||||
value: string;
|
||||
lineNumber: number;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConfigMapImportPropertiesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [propertiesContent, setPropertiesContent] = useState('');
|
||||
const [parsedProperties, setParsedProperties] = useState<ParsedProperty[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const parsePropertiesFile = (content: string): ParsedProperty[] => {
|
||||
const lines = content.split('\n');
|
||||
const properties: ParsedProperty[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmedLine || trimmedLine.startsWith('#') || trimmedLine.startsWith('!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle line continuation with backslash
|
||||
let processedLine = trimmedLine;
|
||||
if (processedLine.endsWith('\\')) {
|
||||
// For simplicity, we'll remove the backslash and continue on the same line
|
||||
processedLine = processedLine.slice(0, -1);
|
||||
}
|
||||
|
||||
// Find the separator (= or : or space)
|
||||
let separatorIndex = -1;
|
||||
let separator = '';
|
||||
|
||||
// Look for = first
|
||||
separatorIndex = processedLine.indexOf('=');
|
||||
if (separatorIndex !== -1) {
|
||||
separator = '=';
|
||||
} else {
|
||||
// Look for :
|
||||
separatorIndex = processedLine.indexOf(':');
|
||||
if (separatorIndex !== -1) {
|
||||
separator = ':';
|
||||
} else {
|
||||
// Look for space
|
||||
separatorIndex = processedLine.indexOf(' ');
|
||||
if (separatorIndex !== -1) {
|
||||
separator = ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
properties.push({
|
||||
key: processedLine,
|
||||
value: '',
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: `Formato inválido: faltando separador (=, :, ou espaço)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = processedLine.substring(0, separatorIndex).trim();
|
||||
const value = processedLine.substring(separatorIndex + 1).trim();
|
||||
|
||||
if (!key) {
|
||||
properties.push({
|
||||
key: '',
|
||||
value: value,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Chave vazia',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process escape sequences in key and value
|
||||
const processedKey = key.replace(/\\(.)/g, '$1');
|
||||
const processedValue = value.replace(/\\(.)/g, '$1');
|
||||
|
||||
// Validate key format (Java properties keys can contain dots, but we'll be more restrictive for ConfigMap)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(processedKey)) {
|
||||
properties.push({
|
||||
key: processedKey,
|
||||
value: processedValue,
|
||||
lineNumber: index + 1,
|
||||
isValid: false,
|
||||
error: 'Nome de chave inválido para ConfigMap',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
properties.push({
|
||||
key: processedKey,
|
||||
value: processedValue,
|
||||
lineNumber: index + 1,
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
return properties;
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setPropertiesContent(content);
|
||||
if (content.trim()) {
|
||||
const parsed = parsePropertiesFile(content);
|
||||
setParsedProperties(parsed);
|
||||
} else {
|
||||
setParsedProperties([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
handleContentChange(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validProperties = parsedProperties.filter((p) => p.isValid);
|
||||
if (validProperties.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nenhuma propriedade válida encontrada',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const validProperties = parsedProperties.filter((p) => p.isValid);
|
||||
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
validProperties.forEach((property) => {
|
||||
command += ` --from-literal="${property.key}"="${property.value}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado com ${validProperties.length} propriedades`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validProperties = parsedProperties.filter((p) => p.isValid);
|
||||
const invalidProperties = parsedProperties.filter((p) => !p.isValid);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Database className="h-8 w-8 mr-3 text-purple-600" />
|
||||
Importar Arquivo .properties
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importe propriedades Java de um arquivo .properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Arquivo .properties
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="propertiesFile">Carregar arquivo .properties</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".properties,.config"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="propertiesContent">
|
||||
Ou cole o conteúdo do arquivo .properties
|
||||
</Label>
|
||||
<Textarea
|
||||
id="propertiesContent"
|
||||
placeholder={`# Exemplo de arquivo .properties
|
||||
# Configurações do banco de dados
|
||||
database.host=localhost
|
||||
database.port=5432
|
||||
database.name=myapp
|
||||
database.ssl=true
|
||||
|
||||
# Configurações da API
|
||||
api.url=https://api.example.com
|
||||
api.timeout=30000
|
||||
api.retries=3
|
||||
|
||||
# Configurações de logging
|
||||
logging.level=INFO
|
||||
logging.format=JSON
|
||||
logging.file=/var/log/app.log
|
||||
|
||||
# Configurações de cache
|
||||
cache.enabled=true
|
||||
cache.size=1000
|
||||
cache.ttl=3600`}
|
||||
value={propertiesContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedProperties.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Eye className="h-5 w-5 mr-2" />
|
||||
Pré-visualização
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showPreview ? 'Ocultar' : 'Mostrar'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{validProperties.length} propriedades válidas
|
||||
</span>
|
||||
</div>
|
||||
{invalidProperties.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{invalidProperties.length} propriedades inválidas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="space-y-4">
|
||||
{validProperties.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">
|
||||
Propriedades Válidas:
|
||||
</h4>
|
||||
<div className="bg-green-50 p-3 rounded-md space-y-1 max-h-60 overflow-y-auto">
|
||||
{validProperties.map((property, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
<span className="text-green-800 font-medium">
|
||||
{property.key}
|
||||
</span>
|
||||
<span className="text-gray-600">=</span>
|
||||
<span className="text-green-700">{property.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidProperties.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">
|
||||
Propriedades Inválidas:
|
||||
</h4>
|
||||
<div className="bg-red-50 p-3 rounded-md space-y-1">
|
||||
{invalidProperties.map((property, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
<div className="font-mono text-red-800">
|
||||
Linha {property.lineNumber}: {property.key || '(vazio)'}
|
||||
</div>
|
||||
<div className="text-red-600 text-xs">
|
||||
{property.error}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConfigMap}
|
||||
disabled={creating || validProperties.length === 0}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportPropertiesPage;
|
||||
715
src/components/pages/configmap-import-url.tsx
Normal file
@@ -0,0 +1,715 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Globe,
|
||||
Download,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Code,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ParsedUrlData {
|
||||
key: string;
|
||||
value: string;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConfigMapImportUrlPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileFormat, setFileFormat] = useState<'auto' | 'env' | 'json' | 'yaml' | 'properties' | 'ini'>('auto');
|
||||
const [fetchedContent, setFetchedContent] = useState('');
|
||||
const [parsedData, setParsedData] = useState<ParsedUrlData[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
const detectFileFormat = (url: string, content: string): 'env' | 'json' | 'yaml' | 'properties' | 'ini' => {
|
||||
// First, try to detect from URL extension
|
||||
const urlLower = url.toLowerCase();
|
||||
if (urlLower.includes('.env')) return 'env';
|
||||
if (urlLower.includes('.json')) return 'json';
|
||||
if (urlLower.includes('.yaml') || urlLower.includes('.yml')) return 'yaml';
|
||||
if (urlLower.includes('.properties')) return 'properties';
|
||||
if (urlLower.includes('.ini') || urlLower.includes('.cfg')) return 'ini';
|
||||
|
||||
// Try to detect from content
|
||||
const lines = content.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'));
|
||||
|
||||
// Check for JSON
|
||||
if (content.trim().startsWith('{') && content.trim().endsWith('}')) {
|
||||
try {
|
||||
JSON.parse(content);
|
||||
return 'json';
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
// Check for YAML (looks for key: value pattern)
|
||||
if (lines.some(line => /^[a-zA-Z_][a-zA-Z0-9_]*:\s*/.test(line.trim()))) {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
// Check for INI (looks for [section] headers)
|
||||
if (lines.some(line => /^\[[^\]]+\]$/.test(line.trim()))) {
|
||||
return 'ini';
|
||||
}
|
||||
|
||||
// Check for properties (key=value with dots in keys)
|
||||
if (lines.some(line => /^[a-zA-Z_][a-zA-Z0-9_.]*\s*=/.test(line.trim()))) {
|
||||
return 'properties';
|
||||
}
|
||||
|
||||
// Default to env format
|
||||
return 'env';
|
||||
};
|
||||
|
||||
const parseContent = (content: string, format: 'env' | 'json' | 'yaml' | 'properties' | 'ini'): ParsedUrlData[] => {
|
||||
const data: ParsedUrlData[] = [];
|
||||
|
||||
try {
|
||||
switch (format) {
|
||||
case 'env':
|
||||
return parseEnvContent(content);
|
||||
case 'json':
|
||||
return parseJsonContent(content);
|
||||
case 'yaml':
|
||||
return parseYamlContent(content);
|
||||
case 'properties':
|
||||
return parsePropertiesContent(content);
|
||||
case 'ini':
|
||||
return parseIniContent(content);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
return [{
|
||||
key: 'parse-error',
|
||||
value: '',
|
||||
isValid: false,
|
||||
error: `Erro ao parsear conteúdo: ${error instanceof Error ? error.message : 'Erro desconhecido'}`,
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
const parseEnvContent = (content: string): ParsedUrlData[] => {
|
||||
const lines = content.split('\n');
|
||||
const data: ParsedUrlData[] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) return;
|
||||
|
||||
const equalsIndex = trimmedLine.indexOf('=');
|
||||
if (equalsIndex === -1) return;
|
||||
|
||||
const key = trimmedLine.substring(0, equalsIndex).trim();
|
||||
const value = trimmedLine.substring(equalsIndex + 1).trim().replace(/^[\"']|[\"']$/g, '');
|
||||
|
||||
if (key && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
data.push({ key, value, isValid: true });
|
||||
} else {
|
||||
data.push({ key, value, isValid: false, error: 'Nome de variável inválido' });
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const parseJsonContent = (content: string): ParsedUrlData[] => {
|
||||
const parsed = JSON.parse(content);
|
||||
const flattened = flattenObject(parsed);
|
||||
const data: ParsedUrlData[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(flattened)) {
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(key)) {
|
||||
data.push({ key, value: stringValue, isValid: true });
|
||||
} else {
|
||||
data.push({ key, value: stringValue, isValid: false, error: 'Nome de chave inválido' });
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const parseYamlContent = (content: string): ParsedUrlData[] => {
|
||||
// Simple YAML parser - basic implementation
|
||||
const lines = content.split('\n');
|
||||
const data: ParsedUrlData[] = [];
|
||||
let currentPath: string[] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) return;
|
||||
|
||||
const indent = line.length - line.trimStart().length;
|
||||
const level = Math.floor(indent / 2);
|
||||
|
||||
const colonIndex = trimmedLine.indexOf(':');
|
||||
if (colonIndex === -1) return;
|
||||
|
||||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||||
const value = trimmedLine.substring(colonIndex + 1).trim();
|
||||
|
||||
// Adjust path based on indentation
|
||||
currentPath = currentPath.slice(0, level);
|
||||
const fullKey = [...currentPath, key].join('.');
|
||||
|
||||
if (value && value !== '') {
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(fullKey)) {
|
||||
data.push({ key: fullKey, value, isValid: true });
|
||||
} else {
|
||||
data.push({ key: fullKey, value, isValid: false, error: 'Nome de chave inválido' });
|
||||
}
|
||||
} else {
|
||||
currentPath.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const parsePropertiesContent = (content: string): ParsedUrlData[] => {
|
||||
const lines = content.split('\n');
|
||||
const data: ParsedUrlData[] = [];
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#') || trimmedLine.startsWith('!')) return;
|
||||
|
||||
const equalsIndex = trimmedLine.indexOf('=');
|
||||
if (equalsIndex === -1) return;
|
||||
|
||||
const key = trimmedLine.substring(0, equalsIndex).trim();
|
||||
const value = trimmedLine.substring(equalsIndex + 1).trim();
|
||||
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(key)) {
|
||||
data.push({ key, value, isValid: true });
|
||||
} else {
|
||||
data.push({ key, value, isValid: false, error: 'Nome de chave inválido' });
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const parseIniContent = (content: string): ParsedUrlData[] => {
|
||||
const lines = content.split('\n');
|
||||
const data: ParsedUrlData[] = [];
|
||||
let currentSection = '';
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith(';') || trimmedLine.startsWith('#')) return;
|
||||
|
||||
if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) {
|
||||
currentSection = trimmedLine.slice(1, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
const equalsIndex = trimmedLine.indexOf('=');
|
||||
if (equalsIndex === -1) return;
|
||||
|
||||
const key = trimmedLine.substring(0, equalsIndex).trim();
|
||||
const value = trimmedLine.substring(equalsIndex + 1).trim();
|
||||
const fullKey = currentSection ? `${currentSection}.${key}` : key;
|
||||
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(fullKey)) {
|
||||
data.push({ key: fullKey, value, isValid: true });
|
||||
} else {
|
||||
data.push({ key: fullKey, value, isValid: false, error: 'Nome de chave inválido' });
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const flattenObject = (obj: any, prefix = ''): Record<string, any> => {
|
||||
const flattened: Record<string, any> = {};
|
||||
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (obj[key] !== null && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||
Object.assign(flattened, flattenObject(obj[key], newKey));
|
||||
} else {
|
||||
flattened[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const handleFetchUrl = async () => {
|
||||
if (!url.trim()) {
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: 'URL é obrigatória',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
setFetchedContent(content);
|
||||
|
||||
// Detect format if auto
|
||||
const detectedFormat = fileFormat === 'auto' ? detectFileFormat(url, content) : fileFormat;
|
||||
const parsed = parseContent(content, detectedFormat);
|
||||
setParsedData(parsed);
|
||||
|
||||
showToast({
|
||||
title: 'Conteúdo Carregado',
|
||||
description: `Arquivo carregado com sucesso. Formato detectado: ${detectedFormat.toUpperCase()}`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
showToast({
|
||||
title: 'Erro ao Carregar URL',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setFetchedContent(content);
|
||||
|
||||
if (content.trim()) {
|
||||
// Detect format if auto
|
||||
const detectedFormat = fileFormat === 'auto' ? detectFileFormat(url, content) : fileFormat;
|
||||
const parsed = parseContent(content, detectedFormat);
|
||||
setParsedData(parsed);
|
||||
} else {
|
||||
setParsedData([]);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
if (validData.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nenhum dado válido encontrado',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
validData.forEach((item) => {
|
||||
command += ` --from-literal="${item.key}"="${item.value}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado com ${validData.length} entradas`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
const invalidData = parsedData.filter((d) => !d.isValid);
|
||||
|
||||
const formatIcons = {
|
||||
auto: Globe,
|
||||
env: FileText,
|
||||
json: Code,
|
||||
yaml: Settings,
|
||||
properties: Database,
|
||||
ini: FileText,
|
||||
};
|
||||
|
||||
const FormatIcon = formatIcons[fileFormat];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Globe className="h-8 w-8 mr-3 text-teal-600" />
|
||||
Importar de URL Remota
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importe configurações de uma URL remota (GitHub, GitLab, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* URL Input */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Globe className="h-5 w-5 mr-2" />
|
||||
URL Remota
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="url">URL do Arquivo</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://raw.githubusercontent.com/user/repo/main/config.env"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Exemplos: GitHub raw, GitLab raw, ou qualquer URL pública
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fileFormat">Formato do Arquivo</Label>
|
||||
<Select value={fileFormat} onValueChange={(value) => setFileFormat(value as 'auto' | 'env' | 'json' | 'yaml' | 'properties' | 'ini')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
<div className="flex items-center">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
Auto-detectar
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="env">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Arquivo .env
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="json">
|
||||
<div className="flex items-center">
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
Arquivo JSON
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="yaml">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Arquivo YAML
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="properties">
|
||||
<div className="flex items-center">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Arquivo .properties
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="ini">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Arquivo .ini
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleFetchUrl}
|
||||
disabled={fetching || !url.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Carregando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Carregar Arquivo
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fetched Content */}
|
||||
{fetchedContent && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FormatIcon className="h-5 w-5 mr-2" />
|
||||
Conteúdo Carregado
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fetchedContent">
|
||||
Edite o conteúdo conforme necessário antes de processar
|
||||
</Label>
|
||||
<Textarea
|
||||
id="fetchedContent"
|
||||
value={fetchedContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
placeholder="Conteúdo carregado aparecerá aqui..."
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
💡 Dica: Você pode editar o conteúdo para remover linhas indesejadas ou fazer ajustes antes de processar
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{parsedData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Eye className="h-5 w-5 mr-2" />
|
||||
Pré-visualização
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showPreview ? 'Ocultar' : 'Mostrar'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{validData.length} entradas válidas
|
||||
</span>
|
||||
</div>
|
||||
{invalidData.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{invalidData.length} entradas inválidas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="space-y-4">
|
||||
{validData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">
|
||||
Entradas Válidas:
|
||||
</h4>
|
||||
<div className="bg-green-50 p-3 rounded-md space-y-1 max-h-60 overflow-y-auto">
|
||||
{validData.map((item, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
<span className="text-green-800 font-medium">
|
||||
{item.key}
|
||||
</span>
|
||||
<span className="text-gray-600">=</span>
|
||||
<span className="text-green-700">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">
|
||||
Entradas Inválidas:
|
||||
</h4>
|
||||
<div className="bg-red-50 p-3 rounded-md space-y-1">
|
||||
{invalidData.map((item, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
<div className="font-mono text-red-800">
|
||||
{item.key || '(erro)'}
|
||||
</div>
|
||||
<div className="text-red-600 text-xs">
|
||||
{item.error}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConfigMap}
|
||||
disabled={creating || validData.length === 0}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportUrlPage;
|
||||
557
src/components/pages/configmap-import-yaml.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ParsedYamlData {
|
||||
key: string;
|
||||
value: string;
|
||||
path: string;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ConfigMapImportYamlPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [configMapName, setConfigMapName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [yamlContent, setYamlContent] = useState('');
|
||||
const [parsedData, setParsedData] = useState<ParsedYamlData[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [flattenMode, setFlattenMode] = useState<'dot' | 'underscore'>('dot');
|
||||
|
||||
const flattenObject = (
|
||||
obj: any,
|
||||
prefix = '',
|
||||
separator = '.',
|
||||
): Record<string, any> => {
|
||||
const flattened: Record<string, any> = {};
|
||||
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const newKey = prefix ? `${prefix}${separator}${key}` : key;
|
||||
|
||||
if (
|
||||
obj[key] !== null &&
|
||||
typeof obj[key] === 'object' &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
Object.assign(flattened, flattenObject(obj[key], newKey, separator));
|
||||
} else {
|
||||
flattened[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const parseYamlFile = (content: string): ParsedYamlData[] => {
|
||||
const data: ParsedYamlData[] = [];
|
||||
|
||||
try {
|
||||
// Simple YAML parser - supports basic key-value pairs and nested objects
|
||||
const lines = content.split('\n');
|
||||
const parsedObj: any = {};
|
||||
let currentPath: string[] = [];
|
||||
let lastIndent = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate indentation
|
||||
const indent = line.length - line.trimStart().length;
|
||||
const indentLevel = Math.floor(indent / 2);
|
||||
|
||||
// Adjust current path based on indentation
|
||||
if (indent < lastIndent) {
|
||||
const levelsUp = Math.floor((lastIndent - indent) / 2);
|
||||
currentPath = currentPath.slice(0, -levelsUp);
|
||||
} else if (indent > lastIndent && currentPath.length > 0) {
|
||||
// Already handled by previous line
|
||||
}
|
||||
|
||||
lastIndent = indent;
|
||||
|
||||
// Check if line contains a key-value pair
|
||||
const colonIndex = trimmedLine.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||||
const value = trimmedLine.substring(colonIndex + 1).trim();
|
||||
|
||||
if (!key) continue;
|
||||
|
||||
// Build the full path
|
||||
const fullPath = [...currentPath, key].join('.');
|
||||
|
||||
if (value) {
|
||||
// Direct value assignment
|
||||
let processedValue = value;
|
||||
|
||||
// Handle different YAML value types
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
processedValue = value.slice(1, -1);
|
||||
} else if (value.startsWith("'") && value.endsWith("'")) {
|
||||
processedValue = value.slice(1, -1);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
processedValue = value;
|
||||
} else if (!isNaN(Number(value))) {
|
||||
processedValue = value;
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
processedValue = value;
|
||||
} else if (value.startsWith('{') && value.endsWith('}')) {
|
||||
processedValue = value;
|
||||
}
|
||||
|
||||
// Set nested value
|
||||
let current = parsedObj;
|
||||
const pathParts = fullPath.split('.');
|
||||
for (let j = 0; j < pathParts.length - 1; j++) {
|
||||
if (!current[pathParts[j]]) {
|
||||
current[pathParts[j]] = {};
|
||||
}
|
||||
current = current[pathParts[j]];
|
||||
}
|
||||
current[pathParts[pathParts.length - 1]] = processedValue;
|
||||
} else {
|
||||
// This is a parent key, update current path
|
||||
currentPath = [...currentPath, key];
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the parsed object
|
||||
const separator = flattenMode === 'dot' ? '.' : '_';
|
||||
const flattened = flattenObject(parsedObj, '', separator);
|
||||
|
||||
for (const [key, value] of Object.entries(flattened)) {
|
||||
// Convert arrays and objects to strings
|
||||
let stringValue: string;
|
||||
if (Array.isArray(value)) {
|
||||
stringValue = JSON.stringify(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
stringValue = JSON.stringify(value);
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
|
||||
// Validate key format (should be valid ConfigMap key)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(key)) {
|
||||
data.push({
|
||||
key,
|
||||
value: stringValue,
|
||||
path: key,
|
||||
isValid: false,
|
||||
error: 'Nome de chave inválido para ConfigMap',
|
||||
});
|
||||
} else {
|
||||
data.push({
|
||||
key,
|
||||
value: stringValue,
|
||||
path: key,
|
||||
isValid: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
data.push({
|
||||
key: 'yaml-parse-error',
|
||||
value: '',
|
||||
path: '',
|
||||
isValid: false,
|
||||
error: `Erro ao parsear YAML: ${error instanceof Error ? error.message : 'Erro desconhecido'}`,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setYamlContent(content);
|
||||
if (content.trim()) {
|
||||
const parsed = parseYamlFile(content);
|
||||
setParsedData(parsed);
|
||||
} else {
|
||||
setParsedData([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
handleContentChange(content);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFlattenModeChange = (mode: 'dot' | 'underscore') => {
|
||||
setFlattenMode(mode);
|
||||
if (yamlContent.trim()) {
|
||||
const parsed = parseYamlFile(yamlContent);
|
||||
setParsedData(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!configMapName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do ConfigMap é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
if (validData.length === 0) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nenhum dado válido encontrado',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateConfigMap = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
|
||||
let command = `kubectl create configmap "${configMapName}" -n "${selectedNamespace}"`;
|
||||
|
||||
validData.forEach((item) => {
|
||||
command += ` --from-literal="${item.key}"="${item.value}"`;
|
||||
});
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Criado com Sucesso',
|
||||
description: `ConfigMap "${configMapName}" foi criado com ${validData.length} chaves`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/storage/configmaps');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar ConfigMap',
|
||||
description: result.error || 'Falha ao criar o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validData = parsedData.filter((d) => d.isValid);
|
||||
const invalidData = parsedData.filter((d) => !d.isValid);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Settings className="h-8 w-8 mr-3 text-orange-600" />
|
||||
Importar Arquivo YAML
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importe configurações de um arquivo YAML
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configMapName">Nome do ConfigMap</Label>
|
||||
<Input
|
||||
id="configMapName"
|
||||
placeholder="ex: app-config"
|
||||
value={configMapName}
|
||||
onChange={(e) => setConfigMapName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="flattenMode">Modo de Achatamento</Label>
|
||||
<Select value={flattenMode} onValueChange={handleFlattenModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dot">
|
||||
Separador por ponto (db.host)
|
||||
</SelectItem>
|
||||
<SelectItem value="underscore">
|
||||
Separador por underscore (db_host)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Arquivo YAML
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="yamlFile">Carregar arquivo YAML</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="yamlContent">Ou cole o conteúdo YAML</Label>
|
||||
<Textarea
|
||||
id="yamlContent"
|
||||
placeholder={`# Exemplo de arquivo YAML
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
name: myapp
|
||||
ssl: true
|
||||
|
||||
api:
|
||||
url: https://api.example.com
|
||||
timeout: 30
|
||||
retries: 3
|
||||
|
||||
features:
|
||||
- auth
|
||||
- logging
|
||||
- metrics
|
||||
|
||||
logging:
|
||||
level: info
|
||||
format: json`}
|
||||
value={yamlContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Eye className="h-5 w-5 mr-2" />
|
||||
Pré-visualização
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showPreview ? 'Ocultar' : 'Mostrar'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{validData.length} chaves válidas
|
||||
</span>
|
||||
</div>
|
||||
{invalidData.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{invalidData.length} chaves inválidas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div className="space-y-4">
|
||||
{validData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">
|
||||
Chaves Válidas:
|
||||
</h4>
|
||||
<div className="bg-green-50 p-3 rounded-md space-y-1 max-h-60 overflow-y-auto">
|
||||
{validData.map((item, index) => (
|
||||
<div key={index} className="text-sm font-mono">
|
||||
<span className="text-green-800 font-medium">
|
||||
{item.key}
|
||||
</span>
|
||||
<span className="text-gray-600">=</span>
|
||||
<span className="text-green-700">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">
|
||||
Chaves Inválidas:
|
||||
</h4>
|
||||
<div className="bg-red-50 p-3 rounded-md space-y-1">
|
||||
{invalidData.map((item, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
<div className="font-mono text-red-800">
|
||||
{item.key || '(erro)'}
|
||||
</div>
|
||||
<div className="text-red-600 text-xs">
|
||||
{item.error}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConfigMap}
|
||||
disabled={creating || validData.length === 0}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportYamlPage;
|
||||
209
src/components/pages/configmap-import.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Upload,
|
||||
Map,
|
||||
Settings,
|
||||
Code,
|
||||
Database,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
function ConfigMapImportPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedImportType, setSelectedImportType] = useState<string>('');
|
||||
|
||||
const importTypes = [
|
||||
{
|
||||
value: 'env',
|
||||
label: 'Arquivo .env',
|
||||
description: 'Importar variáveis de ambiente de arquivo .env',
|
||||
icon: FileText,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50 border-green-200',
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
label: 'Arquivo JSON',
|
||||
description: 'Importar configurações de arquivo JSON',
|
||||
icon: Code,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50 border-blue-200',
|
||||
},
|
||||
{
|
||||
value: 'yaml',
|
||||
label: 'Arquivo YAML',
|
||||
description: 'Importar configurações de arquivo YAML',
|
||||
icon: Settings,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50 border-orange-200',
|
||||
},
|
||||
{
|
||||
value: 'properties',
|
||||
label: 'Arquivo .properties',
|
||||
description: 'Importar propriedades Java (.properties)',
|
||||
icon: Database,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50 border-purple-200',
|
||||
},
|
||||
{
|
||||
value: 'ini',
|
||||
label: 'Arquivo .ini',
|
||||
description: 'Importar configurações de arquivo INI',
|
||||
icon: FileText,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50 border-indigo-200',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: 'URL Remota',
|
||||
description: 'Importar de URL remota (GitHub, GitLab, etc.)',
|
||||
icon: Globe,
|
||||
color: 'text-teal-600',
|
||||
bgColor: 'bg-teal-50 border-teal-200',
|
||||
},
|
||||
];
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedImportType) {
|
||||
// Navegar para a próxima etapa baseada no tipo selecionado
|
||||
navigate(`/storage/configmaps/import/${selectedImportType}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Upload className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Importar ConfigMap
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Escolha o tipo de arquivo ou fonte para importar seu ConfigMap
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Types */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{importTypes.map((type) => {
|
||||
const Icon = type.icon;
|
||||
const isSelected = selectedImportType === type.value;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className={`cursor-pointer transition-all duration-200 hover:shadow-lg ${
|
||||
isSelected
|
||||
? `ring-2 ring-blue-500 ${type.bgColor}`
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => setSelectedImportType(type.value)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon className={`h-8 w-8 ${type.color}`} />
|
||||
{isSelected && (
|
||||
<CheckCircle className="h-6 w-6 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">{type.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{type.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected Type Info */}
|
||||
{selectedImportType && (
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center text-blue-700">
|
||||
<Map className="h-5 w-5 mr-2" />
|
||||
Tipo Selecionado
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-blue-900">
|
||||
{
|
||||
importTypes.find((t) => t.value === selectedImportType)
|
||||
?.label
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
{
|
||||
importTypes.find((t) => t.value === selectedImportType)
|
||||
?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Continuar
|
||||
<ArrowLeft className="h-4 w-4 ml-2 rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card className="border-gray-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center text-gray-700">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
Instruções
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
<p>
|
||||
<strong>📄 .env:</strong> Perfeito para variáveis de ambiente
|
||||
(KEY=value)
|
||||
</p>
|
||||
<p>
|
||||
<strong>📋 JSON:</strong> Ideal para configurações estruturadas
|
||||
</p>
|
||||
<p>
|
||||
<strong>⚙️ YAML:</strong> Formato legível para configurações
|
||||
complexas
|
||||
</p>
|
||||
<p>
|
||||
<strong>☕ .properties:</strong> Padrão Java para configurações
|
||||
</p>
|
||||
<p>
|
||||
<strong>🔧 .ini:</strong> Formato de configuração Windows/Linux
|
||||
</p>
|
||||
<p>
|
||||
<strong>🌐 URL:</strong> Importar diretamente de repositórios
|
||||
remotos
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapImportPage;
|
||||
703
src/components/pages/configmaps.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
useConfigMapsInfo,
|
||||
useKubectl,
|
||||
ConfigMapInfo,
|
||||
} from '@/hooks/useKubectl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
Loader2,
|
||||
Map,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
ArrowUpDown,
|
||||
Eye,
|
||||
FileText,
|
||||
Pause,
|
||||
Play,
|
||||
Key,
|
||||
Settings,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
function ConfigMapsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { configMaps, namespaces, loading, error, refetch, backgroundRefetch } =
|
||||
useConfigMapsInfo();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { showToast } = useToast();
|
||||
const { selectedNamespace, setSelectedNamespace } = useGlobalNamespace();
|
||||
|
||||
const [sortField, setSortField] = useState<string>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [configMapRefreshInterval, setConfigMapRefreshInterval] =
|
||||
useState(30000); // 30 segundos
|
||||
|
||||
// Modal states
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [selectedConfigMap, setSelectedConfigMap] =
|
||||
useState<ConfigMapInfo | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Auto-refresh effect
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return undefined;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
backgroundRefetch();
|
||||
}, configMapRefreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, configMapRefreshInterval, backgroundRefetch]);
|
||||
|
||||
// Filter and sort configmaps
|
||||
const filteredAndSortedConfigMaps = useMemo(() => {
|
||||
let filtered = configMaps;
|
||||
|
||||
// Filter by namespace
|
||||
if (selectedNamespace !== 'all') {
|
||||
filtered = filtered.filter((cm) => cm.namespace === selectedNamespace);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(cm) =>
|
||||
cm.name.toLowerCase().includes(query) ||
|
||||
cm.namespace.toLowerCase().includes(query) ||
|
||||
Object.keys(cm.data).some((key) => key.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
return filtered.sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
break;
|
||||
case 'namespace':
|
||||
aValue = a.namespace;
|
||||
bValue = b.namespace;
|
||||
break;
|
||||
case 'dataCount':
|
||||
aValue = a.dataCount;
|
||||
bValue = b.dataCount;
|
||||
break;
|
||||
case 'age':
|
||||
aValue = new Date(a.creationTimestamp).getTime();
|
||||
bValue = new Date(b.creationTimestamp).getTime();
|
||||
break;
|
||||
default:
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
}
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
if (aValue < bValue) return -1;
|
||||
if (aValue > bValue) return 1;
|
||||
return 0;
|
||||
}
|
||||
if (aValue > bValue) return -1;
|
||||
if (aValue < bValue) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [configMaps, selectedNamespace, searchQuery, sortField, sortDirection]);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getAge = (creationTimestamp: string) => {
|
||||
const now = new Date();
|
||||
const created = new Date(creationTimestamp);
|
||||
const diffMs = now.getTime() - created.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m`;
|
||||
return `${diffSeconds}s`;
|
||||
};
|
||||
|
||||
const handleDeleteConfigMap = async () => {
|
||||
if (!selectedConfigMap) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const command = `kubectl delete configmap ${selectedConfigMap.name} -n ${selectedConfigMap.namespace}`;
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'ConfigMap Excluído',
|
||||
description: `ConfigMap "${selectedConfigMap.name}" foi excluído com sucesso`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
backgroundRefetch();
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Excluir ConfigMap',
|
||||
description: result.error || 'Falha ao excluir o ConfigMap',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedConfigMap(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (configMap: ConfigMapInfo) => {
|
||||
setSelectedConfigMap(configMap);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const statusStats = useMemo(() => {
|
||||
const filtered =
|
||||
selectedNamespace === 'all'
|
||||
? configMaps
|
||||
: configMaps.filter((cm) => cm.namespace === selectedNamespace);
|
||||
|
||||
return {
|
||||
total: filtered.length,
|
||||
withData: filtered.filter((cm) => cm.dataCount > 0).length,
|
||||
empty: filtered.filter((cm) => cm.dataCount === 0).length,
|
||||
totalKeys: filtered.reduce((sum, cm) => sum + cm.dataCount, 0),
|
||||
avgKeys:
|
||||
filtered.length > 0
|
||||
? Math.round(
|
||||
filtered.reduce((sum, cm) => sum + cm.dataCount, 0) /
|
||||
filtered.length,
|
||||
)
|
||||
: 0,
|
||||
};
|
||||
}, [configMaps, selectedNamespace]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Carregando ConfigMaps...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Button onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Map className="h-8 w-8 mr-3 text-blue-600" />
|
||||
ConfigMaps
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gerencie e monitore todos os ConfigMaps do cluster
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Split button - Live/Manual com dropdown de tempo */}
|
||||
<div className="flex">
|
||||
{/* Botão principal - Play/Pause */}
|
||||
<Button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
variant="ghost"
|
||||
className="rounded-r-none border-r-0 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
title={
|
||||
autoRefresh
|
||||
? 'Pausar atualização automática'
|
||||
: 'Ativar atualização automática'
|
||||
}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Live ({configMapRefreshInterval / 1000}s)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Atualizar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Dropdown de tempo */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="rounded-l-none px-2 h-10 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
title="Configurar intervalo de refresh"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Intervalo de Refresh</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfigMapRefreshInterval(1000)}
|
||||
className={
|
||||
configMapRefreshInterval === 1000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
1 segundo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfigMapRefreshInterval(3000)}
|
||||
className={
|
||||
configMapRefreshInterval === 3000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
3 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfigMapRefreshInterval(5000)}
|
||||
className={
|
||||
configMapRefreshInterval === 5000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
5 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfigMapRefreshInterval(7000)}
|
||||
className={
|
||||
configMapRefreshInterval === 7000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
7 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfigMapRefreshInterval(10000)}
|
||||
className={
|
||||
configMapRefreshInterval === 10000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
10 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfigMapRefreshInterval(30000)}
|
||||
className={
|
||||
configMapRefreshInterval === 30000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
30 segundos
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<MoreVertical className="h-4 w-4 mr-2" />
|
||||
Ações
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Ações de ConfigMap</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/storage/configmaps/new')}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/storage/configmaps/import')}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Importar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total</CardTitle>
|
||||
<Map className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{statusStats.total}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ConfigMaps no cluster
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">With Data</CardTitle>
|
||||
<FileText className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{statusStats.withData}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Com configurações</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Empty</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-gray-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{statusStats.empty}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Vazios</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
|
||||
<Key className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{statusStats.totalKeys}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Chaves configuradas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Keys</CardTitle>
|
||||
<Settings className="h-4 w-4 text-purple-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{statusStats.avgKeys}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Média por ConfigMap</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Filter className="h-5 w-5 mr-2" />
|
||||
Filtros
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label className="text-sm font-medium mb-2 block">
|
||||
Namespace
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os namespaces</SelectItem>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>
|
||||
{ns}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label className="text-sm font-medium mb-2 block">Buscar</Label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome, namespace, chave..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ConfigMaps Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{filteredAndSortedConfigMaps.length} ConfigMap(s) encontrado(s)
|
||||
{selectedNamespace !== 'all' && ` em ${selectedNamespace}`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Nome
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('namespace')}
|
||||
>
|
||||
Namespace
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('dataCount')}
|
||||
>
|
||||
Data Keys
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('age')}
|
||||
>
|
||||
Idade
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-16">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedConfigMaps.map((configMap) => {
|
||||
return (
|
||||
<TableRow key={`${configMap.namespace}-${configMap.name}`}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-left font-medium"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/storage/configmaps/${configMap.namespace}/${configMap.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{configMap.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{configMap.namespace}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{configMap.dataCount}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getAge(configMap.creationTimestamp)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Ações</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/storage/configmaps/${configMap.namespace}/${configMap.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Visualizar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(configMap)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filteredAndSortedConfigMaps.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
<div className="text-muted-foreground">
|
||||
<Map className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhum ConfigMap encontrado</p>
|
||||
{(searchQuery || selectedNamespace !== 'all') && (
|
||||
<p className="text-sm mt-1">
|
||||
Tente ajustar os filtros para ver mais resultados
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir ConfigMap</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tem certeza que deseja excluir o ConfigMap{' '}
|
||||
<strong>{selectedConfigMap?.name}</strong> do namespace{' '}
|
||||
<strong>{selectedConfigMap?.namespace}</strong>?
|
||||
<br />
|
||||
<br />
|
||||
Esta ação não pode ser desfeita e pode afetar pods que dependem
|
||||
dessas configurações.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfigMap}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Excluindo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir ConfigMap
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMapsPage;
|
||||
1266
src/components/pages/cronjob-details.tsx
Normal file
2937
src/components/pages/cronjob-details.tsx.backup
Normal file
889
src/components/pages/cronjobs.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
useCronJobsInfo,
|
||||
useKubectl,
|
||||
useClusterEvents,
|
||||
} from '@/hooks/useKubectl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
Loader2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Calendar,
|
||||
Tag,
|
||||
Activity,
|
||||
Server,
|
||||
Container,
|
||||
Zap,
|
||||
MoreVertical,
|
||||
Info,
|
||||
Trash2,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Minus,
|
||||
Pause,
|
||||
Play,
|
||||
Eye,
|
||||
Shield,
|
||||
Database,
|
||||
Key,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function CronJobsPage() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
cronJobs,
|
||||
namespaces,
|
||||
loading,
|
||||
error,
|
||||
isRefreshing,
|
||||
refetch,
|
||||
backgroundRefetch,
|
||||
} = useCronJobsInfo();
|
||||
// const { events: clusterEvents } = useClusterEvents();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { showToast } = useToast();
|
||||
const { selectedNamespace, setSelectedNamespace } = useGlobalNamespace();
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [cronJobToDelete, setCronJobToDelete] = useState<{
|
||||
name: string;
|
||||
namespace: string;
|
||||
} | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [cronJobRefreshInterval, setCronJobRefreshInterval] = useState(10000); // 10 segundos
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Debug logging
|
||||
// console.log(
|
||||
// 'CronJobsPage render - loading:',
|
||||
// loading,
|
||||
// 'error:',
|
||||
// error,
|
||||
// 'cronjobs count:',
|
||||
// cronJobs.length,
|
||||
// );
|
||||
|
||||
// Validate that saved namespace still exists, otherwise reset to 'all'
|
||||
useEffect(() => {
|
||||
if (namespaces.length > 0 && selectedNamespace !== 'all') {
|
||||
if (!namespaces.includes(selectedNamespace)) {
|
||||
setSelectedNamespace('all');
|
||||
}
|
||||
}
|
||||
}, [namespaces, selectedNamespace, setSelectedNamespace]);
|
||||
|
||||
// Intelligent auto-refresh that waits for previous request to complete
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return undefined;
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const scheduleNextRefresh = () => {
|
||||
timeoutId = setTimeout(async () => {
|
||||
// Only refresh if no modals are open and no actions are in progress
|
||||
if (!deleteModalOpen && !deleting && !actionLoading && !isRefreshing) {
|
||||
await backgroundRefetch();
|
||||
}
|
||||
// Schedule the next refresh after this one completes
|
||||
scheduleNextRefresh();
|
||||
}, cronJobRefreshInterval);
|
||||
};
|
||||
|
||||
// Start the first refresh cycle
|
||||
scheduleNextRefresh();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
autoRefresh,
|
||||
cronJobRefreshInterval,
|
||||
deleteModalOpen,
|
||||
deleting,
|
||||
actionLoading,
|
||||
isRefreshing,
|
||||
backgroundRefetch,
|
||||
]);
|
||||
|
||||
const handleDeleteClick = (cronJobName: string, namespaceName: string) => {
|
||||
setCronJobToDelete({ name: cronJobName, namespace: namespaceName });
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!cronJobToDelete) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const command = `kubectl delete cronjob ${cronJobToDelete.name} -n ${cronJobToDelete.namespace}`;
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'CronJob Deletado',
|
||||
description: `O cronjob "${cronJobToDelete.name}" foi deletado com sucesso`,
|
||||
variant: 'success',
|
||||
});
|
||||
setDeleteModalOpen(false);
|
||||
setCronJobToDelete(null);
|
||||
await refetch();
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Deletar CronJob',
|
||||
description: result.error || 'Falha ao deletar cronjob',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCronJobAction = async (
|
||||
cronJobName: string,
|
||||
namespaceName: string,
|
||||
action: string,
|
||||
) => {
|
||||
if (action === 'visualizar') {
|
||||
navigate(`/workloads/cronjobs/${namespaceName}/${cronJobName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(`${namespaceName}/${cronJobName}`);
|
||||
try {
|
||||
let command = '';
|
||||
let successMessage = '';
|
||||
|
||||
switch (action) {
|
||||
case 'suspend':
|
||||
command = `kubectl patch cronjob ${cronJobName} -n ${namespaceName} -p '{"spec":{"suspend":true}}'`;
|
||||
successMessage = `CronJob "${cronJobName}" foi suspenso`;
|
||||
break;
|
||||
case 'resume':
|
||||
command = `kubectl patch cronjob ${cronJobName} -n ${namespaceName} -p '{"spec":{"suspend":false}}'`;
|
||||
successMessage = `CronJob "${cronJobName}" foi retomado`;
|
||||
break;
|
||||
case 'trigger':
|
||||
// Create a job from the cronjob template
|
||||
const timestamp = new Date().getTime();
|
||||
command = `kubectl create job --from=cronjob/${cronJobName} ${cronJobName}-manual-${timestamp} -n ${namespaceName}`;
|
||||
successMessage = `Job manual criado para o CronJob "${cronJobName}"`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Ação desconhecida: ${action}`);
|
||||
}
|
||||
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'Ação Executada',
|
||||
description: successMessage,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
// Aguardar um tempo antes de atualizar para dar tempo do Kubernetes processar
|
||||
setTimeout(async () => {
|
||||
await backgroundRefetch();
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro na Ação',
|
||||
description: result.error || 'Falha na ação do cronjob',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter cronjobs by namespace, status, and search query
|
||||
const filteredCronJobs = useMemo(() => {
|
||||
let filtered = cronJobs;
|
||||
|
||||
// Filter by namespace
|
||||
if (selectedNamespace !== 'all') {
|
||||
filtered = filtered.filter(
|
||||
(cronJob) => cronJob.namespace === selectedNamespace,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter((cronJob) => {
|
||||
const state = getCronJobState(cronJob);
|
||||
switch (statusFilter) {
|
||||
case 'active':
|
||||
return state === 'active';
|
||||
case 'suspended':
|
||||
return state === 'suspended';
|
||||
case 'waiting':
|
||||
return state === 'waiting';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(cronJob) =>
|
||||
cronJob.name.toLowerCase().includes(query) ||
|
||||
cronJob.namespace.toLowerCase().includes(query) ||
|
||||
cronJob.schedule.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [cronJobs, selectedNamespace, statusFilter, searchQuery]);
|
||||
|
||||
const totalCronJobs = filteredCronJobs.length;
|
||||
|
||||
// Calculate time since last schedule
|
||||
const getLastScheduleInfo = (cronJob: any) => {
|
||||
if (!cronJob.lastScheduleTime) {
|
||||
return { text: 'Nunca executado', variant: 'secondary' as const };
|
||||
}
|
||||
|
||||
const lastSchedule = new Date(cronJob.lastScheduleTime);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.floor(
|
||||
(now.getTime() - lastSchedule.getTime()) / (1000 * 60),
|
||||
);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return { text: 'Agora mesmo', variant: 'default' as const };
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return { text: `${diffMinutes}m atrás`, variant: 'default' as const };
|
||||
}
|
||||
if (diffMinutes < 1440) {
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
return { text: `${hours}h atrás`, variant: 'default' as const };
|
||||
}
|
||||
const days = Math.floor(diffMinutes / 1440);
|
||||
return { text: `${days}d atrás`, variant: 'secondary' as const };
|
||||
};
|
||||
|
||||
// Get cronjob status
|
||||
const getCronJobState = (cronJob: any) => {
|
||||
if (cronJob.suspend) {
|
||||
return 'suspended';
|
||||
}
|
||||
if (cronJob.active > 0) {
|
||||
return 'active';
|
||||
}
|
||||
return 'waiting';
|
||||
};
|
||||
|
||||
const activeCronJobs = filteredCronJobs.filter(
|
||||
(cj) => getCronJobState(cj) === 'active',
|
||||
).length;
|
||||
const suspendedCronJobs = filteredCronJobs.filter(
|
||||
(cj) => getCronJobState(cj) === 'suspended',
|
||||
).length;
|
||||
const waitingCronJobs = filteredCronJobs.filter(
|
||||
(cj) => getCronJobState(cj) === 'waiting',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">CronJobs</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gerenciar {totalCronJobs} cronjob(s){' '}
|
||||
{selectedNamespace !== 'all'
|
||||
? `no namespace ${selectedNamespace}`
|
||||
: 'em todos os namespaces'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Split button - Live/Manual com dropdown de tempo */}
|
||||
<div className="flex">
|
||||
{/* Botão principal - Play/Pause */}
|
||||
<Button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
variant="ghost"
|
||||
className="rounded-r-none border-r-0 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
title={
|
||||
autoRefresh
|
||||
? 'Pausar atualização automática'
|
||||
: 'Ativar atualização automática'
|
||||
}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Live ({cronJobRefreshInterval / 1000}s)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Atualizar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Dropdown de tempo */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="rounded-l-none px-2 h-10 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
title="Configurar intervalo de refresh"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Intervalo de Refresh</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCronJobRefreshInterval(1000)}
|
||||
className={
|
||||
cronJobRefreshInterval === 1000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
1 segundo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCronJobRefreshInterval(3000)}
|
||||
className={
|
||||
cronJobRefreshInterval === 3000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
3 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCronJobRefreshInterval(5000)}
|
||||
className={
|
||||
cronJobRefreshInterval === 5000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
5 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCronJobRefreshInterval(10000)}
|
||||
className={
|
||||
cronJobRefreshInterval === 10000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
10 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCronJobRefreshInterval(30000)}
|
||||
className={
|
||||
cronJobRefreshInterval === 30000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
30 segundos
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total</CardTitle>
|
||||
<Clock className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalCronJobs}</div>
|
||||
<p className="text-xs text-muted-foreground">CronJobs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Ativos</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeCronJobs}</div>
|
||||
<p className="text-xs text-muted-foreground">Em execução</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Suspensos</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{suspendedCronJobs}</div>
|
||||
<p className="text-xs text-muted-foreground">Pausados</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Aguardando</CardTitle>
|
||||
<RefreshCw className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{waitingCronJobs}</div>
|
||||
<p className="text-xs text-muted-foreground">Próxima execução</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Schedule</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-yellow-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalCronJobs}</div>
|
||||
<p className="text-xs text-muted-foreground">Agendados</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Jobs</CardTitle>
|
||||
<Activity className="h-4 w-4 text-purple-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeCronJobs}</div>
|
||||
<p className="text-xs text-muted-foreground">Jobs ativos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Filter className="h-5 w-5 mr-2" />
|
||||
Filtros
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Namespace
|
||||
</label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os namespaces</SelectItem>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>
|
||||
{ns}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">Status</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos os status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os status</SelectItem>
|
||||
<SelectItem value="active">Ativo</SelectItem>
|
||||
<SelectItem value="suspended">Suspenso</SelectItem>
|
||||
<SelectItem value="waiting">Aguardando</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">Buscar</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Nome do cronjob, namespace, schedule..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CronJobs Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de CronJobs</CardTitle>
|
||||
<CardDescription>
|
||||
{filteredCronJobs.length} cronjob(s) encontrado(s)
|
||||
{selectedNamespace !== 'all' && ` em ${selectedNamespace}`}
|
||||
{statusFilter !== 'all' && ` com status ${statusFilter}`}
|
||||
{searchQuery && ` contendo "${searchQuery}"`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !isRefreshing ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">Carregando cronjobs...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center p-8 text-red-600">
|
||||
<AlertCircle className="h-8 w-8 mr-2" />
|
||||
<span>Erro: {error}</span>
|
||||
</div>
|
||||
) : filteredCronJobs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhum cronjob encontrado</p>
|
||||
{(statusFilter !== 'all' ||
|
||||
searchQuery ||
|
||||
selectedNamespace !== 'all') && (
|
||||
<p className="text-sm mt-1">
|
||||
Tente ajustar os filtros para ver mais resultados
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead>Última Execução</TableHead>
|
||||
<TableHead>Próxima Execução</TableHead>
|
||||
<TableHead>Jobs Ativos</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCronJobs.map((cronJob, index) => {
|
||||
const state = getCronJobState(cronJob);
|
||||
const lastScheduleInfo = getLastScheduleInfo(cronJob);
|
||||
|
||||
return (
|
||||
<TableRow key={index} className="h-8">
|
||||
<TableCell className="font-medium">
|
||||
<div
|
||||
className="flex items-center space-x-2 cursor-pointer hover:text-blue-600 transition-colors"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/workloads/cronjobs/${cronJob.namespace}/${cronJob.name}`,
|
||||
)
|
||||
}
|
||||
title="Clique para ver detalhes"
|
||||
>
|
||||
<Clock className="h-4 w-4 text-blue-600" />
|
||||
<span className="hover:underline">
|
||||
{cronJob.name}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{cronJob.namespace}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-1 py-0.5 rounded">
|
||||
{cronJob.schedule}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
state === 'active'
|
||||
? 'default'
|
||||
: state === 'suspended'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
className={
|
||||
state === 'active'
|
||||
? 'bg-green-100 text-green-800 border-green-300'
|
||||
: state === 'suspended'
|
||||
? 'bg-orange-100 text-orange-800 border-orange-300'
|
||||
: 'bg-blue-100 text-blue-800 border-blue-300'
|
||||
}
|
||||
>
|
||||
{state === 'active'
|
||||
? 'Ativo'
|
||||
: state === 'suspended'
|
||||
? 'Suspenso'
|
||||
: 'Aguardando'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={lastScheduleInfo.variant}>
|
||||
{lastScheduleInfo.text}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cronJob.nextScheduleTime ? (
|
||||
<span className="text-sm">
|
||||
{new Date(
|
||||
cronJob.nextScheduleTime,
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={cronJob.active > 0 ? 'default' : 'secondary'}
|
||||
>
|
||||
{cronJob.active}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{actionLoading ===
|
||||
`${cronJob.namespace}/${cronJob.name}` && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
Ações do CronJob
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleCronJobAction(
|
||||
cronJob.name,
|
||||
cronJob.namespace,
|
||||
'visualizar',
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
actionLoading ===
|
||||
`${cronJob.namespace}/${cronJob.name}`
|
||||
}
|
||||
>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
Visualizar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleCronJobAction(
|
||||
cronJob.name,
|
||||
cronJob.namespace,
|
||||
'trigger',
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
actionLoading ===
|
||||
`${cronJob.namespace}/${cronJob.name}`
|
||||
}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Executar Agora
|
||||
</DropdownMenuItem>
|
||||
{cronJob.suspend ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleCronJobAction(
|
||||
cronJob.name,
|
||||
cronJob.namespace,
|
||||
'resume',
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
actionLoading ===
|
||||
`${cronJob.namespace}/${cronJob.name}`
|
||||
}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Retomar
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleCronJobAction(
|
||||
cronJob.name,
|
||||
cronJob.namespace,
|
||||
'suspend',
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
actionLoading ===
|
||||
`${cronJob.namespace}/${cronJob.name}`
|
||||
}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Suspender
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleDeleteClick(
|
||||
cronJob.name,
|
||||
cronJob.namespace,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
actionLoading ===
|
||||
`${cronJob.namespace}/${cronJob.name}`
|
||||
}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Deletar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-600">
|
||||
Confirmar Exclusão
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tem certeza que deseja deletar o cronjob{' '}
|
||||
<strong>"{cronJobToDelete?.name}"</strong> do namespace{' '}
|
||||
<strong>"{cronJobToDelete?.namespace}"</strong>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium mb-1">⚠️ Atenção:</p>
|
||||
<p>Esta ação é irreversível e irá deletar:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>O cronjob e sua configuração</li>
|
||||
<li>Todos os jobs agendados futuros</li>
|
||||
<li>Jobs ativos em execução</li>
|
||||
<li>Possível interrupção das tarefas programadas</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setCronJobToDelete(null);
|
||||
}}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{deleting ? 'Deletando...' : 'Sim, Deletar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2241
src/components/pages/daemonset-details.tsx
Normal file
782
src/components/pages/daemonset-import-ecs.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Cloud,
|
||||
FileText,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ECSTaskDefinition {
|
||||
family: string;
|
||||
containerDefinitions: Array<{
|
||||
name: string;
|
||||
image: string;
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
memoryReservation?: number;
|
||||
essential?: boolean;
|
||||
portMappings?: Array<{
|
||||
name?: string;
|
||||
containerPort: number;
|
||||
hostPort?: number;
|
||||
protocol?: string;
|
||||
}>;
|
||||
environment?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
secrets?: Array<{
|
||||
name: string;
|
||||
valueFrom: string;
|
||||
}>;
|
||||
mountPoints?: Array<{
|
||||
sourceVolume: string;
|
||||
containerPath: string;
|
||||
readOnly?: boolean;
|
||||
}>;
|
||||
logConfiguration?: {
|
||||
logDriver: string;
|
||||
options?: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
volumes?: Array<{
|
||||
name: string;
|
||||
host?: {
|
||||
sourcePath?: string;
|
||||
};
|
||||
}>;
|
||||
networkMode?: string;
|
||||
requiresCompatibilities?: string[];
|
||||
cpu?: string;
|
||||
memory?: string;
|
||||
executionRoleArn?: string;
|
||||
taskRoleArn?: string;
|
||||
}
|
||||
|
||||
export function DaemonSetImportECSPage() {
|
||||
const navigate = useNavigate();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { showToast } = useToast();
|
||||
const { selectedNamespace, setSelectedNamespace } = useGlobalNamespace();
|
||||
const { namespaces: namespacesData, loading: namespacesLoading } =
|
||||
useNamespacesInfo();
|
||||
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [daemonSetName, setDaemonSetName] = useState('');
|
||||
const [parsedConfig, setParsedConfig] = useState<ECSTaskDefinition | null>(
|
||||
null,
|
||||
);
|
||||
const [parseError, setParseError] = useState('');
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [createService, setCreateService] = useState(() => {
|
||||
const saved = localStorage.getItem('ecs-import-create-service');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const [createConfigMap, setCreateConfigMap] = useState(() => {
|
||||
const saved = localStorage.getItem('ecs-import-create-configmap');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const [createSecrets, setCreateSecrets] = useState(() => {
|
||||
const saved = localStorage.getItem('ecs-import-create-secrets');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Get namespace names from the namespace objects
|
||||
const namespaces = namespacesData.map((ns) => ns.name).sort();
|
||||
|
||||
// Salva as preferências quando mudarem
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ecs-import-create-service', createService.toString());
|
||||
}, [createService]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(
|
||||
'ecs-import-create-configmap',
|
||||
createConfigMap.toString(),
|
||||
);
|
||||
}, [createConfigMap]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ecs-import-create-secrets', createSecrets.toString());
|
||||
}, [createSecrets]);
|
||||
|
||||
// Function to sanitize names for Kubernetes resources
|
||||
const sanitizeKubernetesName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens
|
||||
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||
.replace(/-+/g, '-'); // Replace multiple hyphens with single
|
||||
};
|
||||
|
||||
const handleFileSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||
showToast({
|
||||
title: 'Arquivo Inválido',
|
||||
description: 'Por favor, selecione um arquivo .json',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setJsonInput(content);
|
||||
|
||||
// Auto-parse the JSON
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
setParsedConfig(parsed);
|
||||
setParseError('');
|
||||
|
||||
// Auto-fill deployment name from family
|
||||
if (parsed.family && !daemonSetName) {
|
||||
setDaemonSetName(sanitizeKubernetesName(parsed.family));
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: 'Arquivo Carregado',
|
||||
description: `Task Definition "${parsed.family}" carregada com sucesso`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
setParseError('JSON inválido no arquivo selecionado.');
|
||||
setParsedConfig(null);
|
||||
showToast({
|
||||
title: 'Erro no Arquivo',
|
||||
description: 'O arquivo selecionado não contém JSON válido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// Reset the input to allow selecting the same file again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleParseJSON = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput);
|
||||
setParsedConfig(parsed);
|
||||
setParseError('');
|
||||
|
||||
// Auto-fill deployment name from family
|
||||
if (parsed.family && !daemonSetName) {
|
||||
setDaemonSetName(sanitizeKubernetesName(parsed.family));
|
||||
}
|
||||
} catch (error) {
|
||||
setParseError('JSON inválido. Verifique a sintaxe.');
|
||||
setParsedConfig(null);
|
||||
}
|
||||
};
|
||||
|
||||
const createConfigMapYaml = (ecsConfig: ECSTaskDefinition): string | null => {
|
||||
const container = ecsConfig.containerDefinitions[0];
|
||||
const envVars = container.environment || [];
|
||||
|
||||
if (envVars.length === 0) return null;
|
||||
|
||||
const dataEntries = envVars
|
||||
.map((env) => ` ${env.name}: "${env.value}"`)
|
||||
.join('\n');
|
||||
|
||||
return `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${daemonSetName}-config
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${daemonSetName}
|
||||
imported-from: aws-ecs
|
||||
data:
|
||||
${dataEntries}`;
|
||||
};
|
||||
|
||||
const createSecretYaml = (ecsConfig: ECSTaskDefinition): string | null => {
|
||||
const container = ecsConfig.containerDefinitions[0];
|
||||
const secrets = container.secrets || [];
|
||||
|
||||
if (secrets.length === 0) return null;
|
||||
|
||||
const dataEntries = secrets
|
||||
.map((secret) => ` ${secret.name}: Y2hhbmdlbWU=`)
|
||||
.join('\n');
|
||||
|
||||
return `apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${daemonSetName}-secrets
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${daemonSetName}
|
||||
imported-from: aws-ecs
|
||||
type: Opaque
|
||||
data:
|
||||
${dataEntries}`;
|
||||
};
|
||||
|
||||
const createServiceYaml = (ecsConfig: ECSTaskDefinition): string | null => {
|
||||
const container = ecsConfig.containerDefinitions[0];
|
||||
const ports = container.portMappings || [];
|
||||
|
||||
if (ports.length === 0) return null;
|
||||
|
||||
const portEntries = ports
|
||||
.map((port) => {
|
||||
const portName = port.name
|
||||
? sanitizeKubernetesName(port.name)
|
||||
: `port-${port.containerPort}`;
|
||||
|
||||
return ` - name: "${portName}"
|
||||
port: ${port.containerPort}
|
||||
targetPort: ${port.containerPort}
|
||||
protocol: ${port.protocol?.toUpperCase() || 'TCP'}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${daemonSetName}-service
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${daemonSetName}
|
||||
imported-from: aws-ecs
|
||||
spec:
|
||||
selector:
|
||||
app: ${daemonSetName}
|
||||
ports:
|
||||
${portEntries}
|
||||
type: ClusterIP`;
|
||||
};
|
||||
|
||||
const convertECSToKubernetes = (ecsConfig: ECSTaskDefinition): string => {
|
||||
const container = ecsConfig.containerDefinitions[0]; // Assume primeiro container
|
||||
|
||||
// Add secrets if creating them
|
||||
const secretEnvVars: Array<string> = [];
|
||||
const secrets = container.secrets || [];
|
||||
if (createSecrets && secrets.length > 0) {
|
||||
secrets.forEach((secret) => {
|
||||
secretEnvVars.push(` - name: ${secret.name}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${daemonSetName}-secrets
|
||||
key: ${secret.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Direct environment variables if not using ConfigMap
|
||||
const directEnvVars: Array<string> = [];
|
||||
if (!createConfigMap && container.environment) {
|
||||
container.environment.forEach((env) => {
|
||||
directEnvVars.push(` - name: ${env.name}
|
||||
value: "${env.value}"`);
|
||||
});
|
||||
}
|
||||
|
||||
const allEnvVars = [...directEnvVars, ...secretEnvVars];
|
||||
const envSection =
|
||||
allEnvVars.length > 0
|
||||
? `
|
||||
env:
|
||||
${allEnvVars.join('\n')}`
|
||||
: '';
|
||||
|
||||
// ConfigMap environment
|
||||
const configMapEnvSection =
|
||||
createConfigMap &&
|
||||
container.environment &&
|
||||
container.environment.length > 0
|
||||
? `
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: ${daemonSetName}-config`
|
||||
: '';
|
||||
|
||||
// Ports section
|
||||
const portsSection =
|
||||
container.portMappings && container.portMappings.length > 0
|
||||
? `
|
||||
ports:
|
||||
${container.portMappings
|
||||
.map(
|
||||
(port) => ` - containerPort: ${port.containerPort}
|
||||
protocol: ${port.protocol?.toUpperCase() || 'TCP'}`,
|
||||
)
|
||||
.join('\n')}`
|
||||
: '';
|
||||
|
||||
// Resources section
|
||||
const resourcesSection =
|
||||
container.cpu || container.memory
|
||||
? `
|
||||
resources:
|
||||
requests:${
|
||||
container.cpu
|
||||
? `
|
||||
cpu: "${container.cpu}m"`
|
||||
: ''
|
||||
}${
|
||||
container.memory
|
||||
? `
|
||||
memory: "${container.memory}Mi"`
|
||||
: ''
|
||||
}`
|
||||
: '';
|
||||
|
||||
// Volume mounts
|
||||
const volumeMounts = container.mountPoints || [];
|
||||
const volumeMountsSection =
|
||||
volumeMounts.length > 0
|
||||
? `
|
||||
volumeMounts:
|
||||
${volumeMounts
|
||||
.map(
|
||||
(mount) => ` - name: ${mount.sourceVolume}
|
||||
mountPath: ${mount.containerPath}
|
||||
readOnly: ${mount.readOnly || false}`,
|
||||
)
|
||||
.join('\n')}`
|
||||
: '';
|
||||
|
||||
// Volumes section for DaemonSet
|
||||
const volumes = ecsConfig.volumes || [];
|
||||
const volumesSection =
|
||||
volumes.length > 0
|
||||
? `
|
||||
volumes:
|
||||
${volumes
|
||||
.map(
|
||||
(volume) => ` - name: ${volume.name}
|
||||
hostPath:
|
||||
path: ${volume.host?.sourcePath || '/tmp'}`,
|
||||
)
|
||||
.join('\n')}`
|
||||
: '';
|
||||
|
||||
return `apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: ${daemonSetName}
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${daemonSetName}
|
||||
imported-from: aws-ecs
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${daemonSetName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${daemonSetName}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${sanitizeKubernetesName(container.name)}
|
||||
image: ${container.image}${envSection}${configMapEnvSection}${portsSection}${resourcesSection}${volumeMountsSection}${volumesSection}`;
|
||||
};
|
||||
|
||||
const handleCreateDaemonSet = async () => {
|
||||
if (!parsedConfig || !daemonSetName) return;
|
||||
|
||||
setConverting(true);
|
||||
try {
|
||||
const resources: string[] = [];
|
||||
|
||||
// Create ConfigMap if needed
|
||||
if (createConfigMap) {
|
||||
const configMapYaml = createConfigMapYaml(parsedConfig);
|
||||
if (configMapYaml) {
|
||||
resources.push(configMapYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Secret if needed
|
||||
if (createSecrets) {
|
||||
const secretYaml = createSecretYaml(parsedConfig);
|
||||
if (secretYaml) {
|
||||
resources.push(secretYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Create DaemonSet
|
||||
const kubernetesConfig = convertECSToKubernetes(parsedConfig);
|
||||
resources.push(kubernetesConfig);
|
||||
|
||||
// Create Service if needed
|
||||
if (createService) {
|
||||
const serviceYaml = createServiceYaml(parsedConfig);
|
||||
if (serviceYaml) {
|
||||
resources.push(serviceYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all resources
|
||||
const allResources = resources.join('\n---\n');
|
||||
const tempFile = `/tmp/ecs-import-${Date.now()}.yaml`;
|
||||
const writeCommand = `echo '${allResources.replace(/'/g, "'\"'\"'")}' > ${tempFile}`;
|
||||
|
||||
await executeCommand(writeCommand);
|
||||
const applyResult = await executeCommand(`kubectl apply -f ${tempFile}`);
|
||||
|
||||
// Cleanup
|
||||
executeCommand(`rm -f ${tempFile}`);
|
||||
|
||||
if (applyResult.success) {
|
||||
const resourcesCreated = [];
|
||||
if (createConfigMap && createConfigMapYaml(parsedConfig))
|
||||
resourcesCreated.push('ConfigMap');
|
||||
if (createSecrets && createSecretYaml(parsedConfig))
|
||||
resourcesCreated.push('Secret');
|
||||
resourcesCreated.push('DaemonSet');
|
||||
if (createService && createServiceYaml(parsedConfig))
|
||||
resourcesCreated.push('Service');
|
||||
|
||||
showToast({
|
||||
title: 'Recursos Criados',
|
||||
description: `${resourcesCreated.join(', ')} criados com sucesso: "${daemonSetName}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
// Navigate immediately to daemonsets list
|
||||
navigate('/workloads/daemonsets');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Recursos',
|
||||
description: applyResult.error || 'Falha ao aplicar configuração',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: error instanceof Error ? error.message : 'Erro inesperado',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Cloud className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Importar AWS ECS Task Definition
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cole o JSON da Task Definition do ECS para converter em DaemonSet
|
||||
Kubernetes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard Steps */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-medium">
|
||||
1
|
||||
</div>
|
||||
<span className="ml-2 font-medium">Task Definition</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
2
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Configuração</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
3
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Revisão</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Input Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
Task Definition JSON
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="json-input">Cole o JSON da Task Definition</Label>
|
||||
<Textarea
|
||||
id="json-input"
|
||||
placeholder='{"family": "my-app", "containerDefinitions": [...], ...}'
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleFileSelect} variant="outline">
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Localizar
|
||||
</Button>
|
||||
<Button onClick={handleParseJSON} disabled={!jsonInput.trim()}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Analisar JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<div className="flex items-center text-red-600 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedConfig && (
|
||||
<div className="flex items-center text-green-600 text-sm">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
JSON válido! Family: {parsedConfig.family}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configuration Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuração do DaemonSet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="daemonset-name">Nome do DaemonSet</Label>
|
||||
<Input
|
||||
id="daemonset-name"
|
||||
placeholder="my-app"
|
||||
value={daemonSetName}
|
||||
onChange={(e) => setDaemonSetName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={
|
||||
selectedNamespace === 'all' ? 'default' : selectedNamespace
|
||||
}
|
||||
onValueChange={setSelectedNamespace}
|
||||
disabled={namespacesLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
namespacesLoading
|
||||
? 'Carregando namespaces...'
|
||||
: 'Selecione o namespace'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>
|
||||
{ns}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{parsedConfig && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Recursos a Criar:</h4>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-service"
|
||||
checked={createService}
|
||||
onChange={(e) => setCreateService(e.target.checked)}
|
||||
disabled={
|
||||
!parsedConfig.containerDefinitions[0]?.portMappings
|
||||
?.length
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="create-service" className="text-sm">
|
||||
Service{' '}
|
||||
{!parsedConfig.containerDefinitions[0]?.portMappings
|
||||
?.length && '(sem portas disponíveis)'}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-configmap"
|
||||
checked={createConfigMap}
|
||||
onChange={(e) => setCreateConfigMap(e.target.checked)}
|
||||
disabled={
|
||||
!parsedConfig.containerDefinitions[0]?.environment
|
||||
?.length
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="create-configmap" className="text-sm">
|
||||
ConfigMap{' '}
|
||||
{!parsedConfig.containerDefinitions[0]?.environment
|
||||
?.length && '(sem variáveis de ambiente)'}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-secrets"
|
||||
checked={createSecrets}
|
||||
onChange={(e) => setCreateSecrets(e.target.checked)}
|
||||
disabled={
|
||||
!parsedConfig.containerDefinitions[0]?.secrets?.length
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="create-secrets" className="text-sm">
|
||||
Secrets{' '}
|
||||
{!parsedConfig.containerDefinitions[0]?.secrets?.length &&
|
||||
'(sem secrets disponíveis)'}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium mb-2">Preview da Configuração:</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<strong>Container:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.name}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Imagem:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.image}
|
||||
</p>
|
||||
<p>
|
||||
<strong>CPU:</strong>{' '}
|
||||
{parsedConfig.cpu || 'Não especificado'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Memória:</strong>{' '}
|
||||
{parsedConfig.memory || 'Não especificado'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Portas:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.portMappings
|
||||
?.map((p) => p.containerPort)
|
||||
.join(', ') || 'Nenhuma'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Environment Vars:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.environment
|
||||
?.length || 0}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Secrets:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.secrets?.length ||
|
||||
0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(createService || createConfigMap || createSecrets) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Recursos que serão criados:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline">DaemonSet</Badge>
|
||||
{createService && createServiceYaml(parsedConfig) && (
|
||||
<Badge variant="outline">Service</Badge>
|
||||
)}
|
||||
{createConfigMap &&
|
||||
createConfigMapYaml(parsedConfig) && (
|
||||
<Badge variant="outline">ConfigMap</Badge>
|
||||
)}
|
||||
{createSecrets && createSecretYaml(parsedConfig) && (
|
||||
<Badge variant="outline">Secret</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreateDaemonSet}
|
||||
disabled={!parsedConfig || !daemonSetName || converting}
|
||||
>
|
||||
{converting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando DaemonSet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Criar DaemonSet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
src/components/pages/daemonset-import.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Upload,
|
||||
Container,
|
||||
GitBranch,
|
||||
Database,
|
||||
Globe,
|
||||
Cloud,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DaemonSetImportPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedImportType, setSelectedImportType] = useState<string>('');
|
||||
|
||||
const importTypes = [
|
||||
{
|
||||
value: 'yaml',
|
||||
label: 'YAML/JSON File',
|
||||
description: 'Importar de arquivo YAML ou JSON local',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
value: 'ecs',
|
||||
label: 'AWS ECS Task Definition',
|
||||
description: 'Converter Task Definition do ECS para Kubernetes',
|
||||
icon: Cloud,
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: 'URL Remote',
|
||||
description: 'Importar de URL remota (GitHub, GitLab, etc.)',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
value: 'template',
|
||||
label: 'Template Predefinido',
|
||||
description: 'Usar template de DaemonSet comum',
|
||||
icon: Container,
|
||||
},
|
||||
{
|
||||
value: 'docker',
|
||||
label: 'Docker Image',
|
||||
description: 'Criar DaemonSet a partir de imagem Docker',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
value: 'git',
|
||||
label: 'Repositório Git',
|
||||
description: 'Clonar e deployar de repositório Git',
|
||||
icon: GitBranch,
|
||||
},
|
||||
];
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedImportType) {
|
||||
// Navegar para a próxima etapa baseada no tipo selecionado
|
||||
navigate(`/workloads/daemonsets/import/${selectedImportType}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Upload className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Assistente de Importação
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Escolha o tipo de importação para criar um novo DaemonSet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard Steps */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-medium">
|
||||
1
|
||||
</div>
|
||||
<span className="ml-2 font-medium">Tipo de Importação</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
2
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Configuração</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
3
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Revisão</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Type Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selecione o Tipo de Importação</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Select onValueChange={setSelectedImportType}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Escolha como você deseja importar o DaemonSet..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{importTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex items-center">
|
||||
<type.icon className="h-4 w-4 mr-2" />
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Detailed Options */}
|
||||
{selectedImportType && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{importTypes
|
||||
.filter((type) => type.value === selectedImportType)
|
||||
.map((type) => {
|
||||
const IconComponent = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className="border-2 border-blue-500 bg-blue-50"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center text-lg">
|
||||
<IconComponent className="h-5 w-5 mr-2 text-blue-600" />
|
||||
{type.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{type.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Options Preview */}
|
||||
{!selectedImportType && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{importTypes.map((type) => {
|
||||
const IconComponent = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => setSelectedImportType(type.value)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center text-lg">
|
||||
<IconComponent className="h-5 w-5 mr-2 text-gray-600" />
|
||||
{type.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{type.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleContinue} disabled={!selectedImportType}>
|
||||
Continuar
|
||||
<ArrowLeft className="h-4 w-4 ml-2 rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
852
src/components/pages/daemonsets.tsx
Normal file
@@ -0,0 +1,852 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
useDaemonSetsInfo,
|
||||
useKubectl,
|
||||
useClusterEvents,
|
||||
useImageDetails,
|
||||
ImageDetails,
|
||||
DaemonSetInfo,
|
||||
} from '@/hooks/useKubectl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
Loader2,
|
||||
Shield,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Calendar,
|
||||
Tag,
|
||||
Activity,
|
||||
Server,
|
||||
Container,
|
||||
Zap,
|
||||
MoreVertical,
|
||||
Info,
|
||||
Trash2,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Minus,
|
||||
Pause,
|
||||
Play,
|
||||
Eye,
|
||||
Database,
|
||||
Key,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DaemonSetsPage() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
daemonSets,
|
||||
namespaces,
|
||||
loading,
|
||||
error,
|
||||
isRefreshing,
|
||||
refetch,
|
||||
backgroundRefetch,
|
||||
} = useDaemonSetsInfo();
|
||||
const { events: clusterEvents } = useClusterEvents();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { getImageDetails } = useImageDetails();
|
||||
const { showToast } = useToast();
|
||||
const { selectedNamespace, setSelectedNamespace } = useGlobalNamespace();
|
||||
|
||||
const [sortField, setSortField] = useState<string>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [daemonSetRefreshInterval, setDaemonSetRefreshInterval] =
|
||||
useState(5000); // 5 segundos
|
||||
|
||||
// Modal states
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageDetailsModalOpen, setImageDetailsModalOpen] = useState(false);
|
||||
const [selectedDaemonSet, setSelectedDaemonSet] =
|
||||
useState<DaemonSetInfo | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [selectedImageDetails, setSelectedImageDetails] =
|
||||
useState<ImageDetails | null>(null);
|
||||
const [imageDetailsLoading, setImageDetailsLoading] = useState(false);
|
||||
|
||||
// Auto-refresh effect
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
backgroundRefetch();
|
||||
}, daemonSetRefreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, daemonSetRefreshInterval, backgroundRefetch]);
|
||||
|
||||
// Filter and sort daemonsets
|
||||
const filteredAndSortedDaemonSets = useMemo(() => {
|
||||
let filtered = daemonSets;
|
||||
|
||||
// Filter by namespace
|
||||
if (selectedNamespace !== 'all') {
|
||||
filtered = filtered.filter((ds) => ds.namespace === selectedNamespace);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter((ds) => {
|
||||
const status = ds.status.toLowerCase();
|
||||
return status === statusFilter.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(ds) =>
|
||||
ds.name.toLowerCase().includes(query) ||
|
||||
ds.namespace.toLowerCase().includes(query) ||
|
||||
ds.images.some((image) => image.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
return filtered.sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
break;
|
||||
case 'namespace':
|
||||
aValue = a.namespace;
|
||||
bValue = b.namespace;
|
||||
break;
|
||||
case 'desired':
|
||||
aValue = a.desiredNumberScheduled;
|
||||
bValue = b.desiredNumberScheduled;
|
||||
break;
|
||||
case 'current':
|
||||
aValue = a.currentNumberScheduled;
|
||||
bValue = b.currentNumberScheduled;
|
||||
break;
|
||||
case 'ready':
|
||||
aValue = a.numberReady;
|
||||
bValue = b.numberReady;
|
||||
break;
|
||||
case 'status':
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
case 'age':
|
||||
aValue = new Date(a.creationTimestamp).getTime();
|
||||
bValue = new Date(b.creationTimestamp).getTime();
|
||||
break;
|
||||
default:
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
}
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
}
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
});
|
||||
}, [
|
||||
daemonSets,
|
||||
selectedNamespace,
|
||||
statusFilter,
|
||||
searchQuery,
|
||||
sortField,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getAge = (creationTimestamp: string) => {
|
||||
const now = new Date();
|
||||
const created = new Date(creationTimestamp);
|
||||
const diffMs = now.getTime() - created.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m`;
|
||||
return `${diffSeconds}s`;
|
||||
};
|
||||
|
||||
const getDaemonSetStatus = (daemonSet: DaemonSetInfo) => {
|
||||
if (daemonSet.hasPodIssues) {
|
||||
return {
|
||||
label: 'Issues',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
icon: XCircle,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
daemonSet.numberReady === daemonSet.desiredNumberScheduled &&
|
||||
daemonSet.numberMisscheduled === 0
|
||||
) {
|
||||
return {
|
||||
label: 'Ready',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
icon: CheckCircle,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Not Ready',
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-100',
|
||||
icon: Clock,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDeleteDaemonSet = async () => {
|
||||
if (!selectedDaemonSet) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const command = `kubectl delete daemonset ${selectedDaemonSet.name} -n ${selectedDaemonSet.namespace}`;
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'DaemonSet Excluído',
|
||||
description: `DaemonSet "${selectedDaemonSet.name}" foi excluído com sucesso`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
backgroundRefetch();
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Excluir DaemonSet',
|
||||
description: result.error || 'Falha ao excluir o DaemonSet',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedDaemonSet(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (daemonSet: DaemonSetInfo) => {
|
||||
setSelectedDaemonSet(daemonSet);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const statusStats = useMemo(() => {
|
||||
const filtered =
|
||||
selectedNamespace === 'all'
|
||||
? daemonSets
|
||||
: daemonSets.filter((ds) => ds.namespace === selectedNamespace);
|
||||
|
||||
return {
|
||||
total: filtered.length,
|
||||
ready: filtered.filter(
|
||||
(ds) =>
|
||||
ds.numberReady === ds.desiredNumberScheduled &&
|
||||
ds.numberMisscheduled === 0 &&
|
||||
!ds.hasPodIssues,
|
||||
).length,
|
||||
notReady: filtered.filter(
|
||||
(ds) =>
|
||||
ds.numberReady !== ds.desiredNumberScheduled ||
|
||||
ds.numberMisscheduled > 0 ||
|
||||
ds.hasPodIssues,
|
||||
).length,
|
||||
withIssues: filtered.filter((ds) => ds.hasPodIssues).length,
|
||||
misscheduled: filtered.filter((ds) => ds.numberMisscheduled > 0).length,
|
||||
};
|
||||
}, [daemonSets, selectedNamespace]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Carregando DaemonSets...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Button onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Shield className="h-8 w-8 mr-3 text-blue-600" />
|
||||
DaemonSets
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gerencie e monitore todos os DaemonSets do cluster
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Split button - Live/Manual com dropdown de tempo */}
|
||||
<div className="flex">
|
||||
{/* Botão principal - Play/Pause */}
|
||||
<Button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
variant="ghost"
|
||||
className="rounded-r-none border-r-0 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
title={
|
||||
autoRefresh
|
||||
? 'Pausar atualização automática'
|
||||
: 'Ativar atualização automática'
|
||||
}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Live ({daemonSetRefreshInterval / 1000}s)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Atualizar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Dropdown de tempo */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="rounded-l-none px-2 h-10 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
title="Configurar intervalo de refresh"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Intervalo de Refresh</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDaemonSetRefreshInterval(1000)}
|
||||
className={
|
||||
daemonSetRefreshInterval === 1000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
1 segundo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDaemonSetRefreshInterval(3000)}
|
||||
className={
|
||||
daemonSetRefreshInterval === 3000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
3 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDaemonSetRefreshInterval(5000)}
|
||||
className={
|
||||
daemonSetRefreshInterval === 5000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
5 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDaemonSetRefreshInterval(10000)}
|
||||
className={
|
||||
daemonSetRefreshInterval === 10000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
10 segundos
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDaemonSetRefreshInterval(30000)}
|
||||
className={
|
||||
daemonSetRefreshInterval === 30000 ? 'bg-green-100' : ''
|
||||
}
|
||||
>
|
||||
30 segundos
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<MoreVertical className="h-4 w-4 mr-2" />
|
||||
Ações
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Ações de DaemonSet</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate('/workloads/daemonsets/import')}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Importar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total</CardTitle>
|
||||
<Shield className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{statusStats.total}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
DaemonSets no cluster
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Ready</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{statusStats.ready}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Prontos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Not Ready</CardTitle>
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{statusStats.notReady}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Não prontos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">With Issues</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{statusStats.withIssues}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Com problemas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Misscheduled</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{statusStats.misscheduled}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Mal agendados</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Filter className="h-5 w-5 mr-2" />
|
||||
Filtros
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Namespace
|
||||
</label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os namespaces</SelectItem>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>
|
||||
{ns}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">Status</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos os status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os status</SelectItem>
|
||||
<SelectItem value="ready">Ready</SelectItem>
|
||||
<SelectItem value="not ready">Not Ready</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block">Buscar</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome, namespace, imagem..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DaemonSets Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{filteredAndSortedDaemonSets.length} DaemonSet(s) encontrado(s)
|
||||
{selectedNamespace !== 'all' && ` em ${selectedNamespace}`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Nome
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('namespace')}
|
||||
>
|
||||
Namespace
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('desired')}
|
||||
>
|
||||
Desired
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('current')}
|
||||
>
|
||||
Current
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('ready')}
|
||||
>
|
||||
Ready
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
Status
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold"
|
||||
onClick={() => handleSort('age')}
|
||||
>
|
||||
Idade
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-16">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedDaemonSets.map((daemonSet) => {
|
||||
const status = getDaemonSetStatus(daemonSet);
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<TableRow key={`${daemonSet.namespace}-${daemonSet.name}`}>
|
||||
<TableCell>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline text-left font-medium"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/workloads/daemonsets/${daemonSet.namespace}/${daemonSet.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{daemonSet.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{daemonSet.namespace}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{daemonSet.desiredNumberScheduled}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{daemonSet.currentNumberScheduled}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{daemonSet.numberReady}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<StatusIcon
|
||||
className={`h-4 w-4 mr-2 ${status.color}`}
|
||||
/>
|
||||
<Badge className={`${status.bgColor} ${status.color}`}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
{daemonSet.numberMisscheduled > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-orange-600 border-orange-600"
|
||||
>
|
||||
{daemonSet.numberMisscheduled} misscheduled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getAge(daemonSet.creationTimestamp)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Ações</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/workloads/daemonsets/${daemonSet.namespace}/${daemonSet.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Visualizar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(daemonSet)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filteredAndSortedDaemonSets.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
<div className="text-muted-foreground">
|
||||
<Shield className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhum DaemonSet encontrado</p>
|
||||
{(statusFilter !== 'all' ||
|
||||
searchQuery ||
|
||||
selectedNamespace !== 'all') && (
|
||||
<p className="text-sm mt-1">
|
||||
Tente ajustar os filtros para ver mais resultados
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir DaemonSet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tem certeza que deseja excluir o DaemonSet{' '}
|
||||
<strong>{selectedDaemonSet?.name}</strong> do namespace{' '}
|
||||
<strong>{selectedDaemonSet?.namespace}</strong>?
|
||||
<br />
|
||||
<br />
|
||||
Esta ação não pode ser desfeita e todos os pods associados serão
|
||||
removidos de todos os nós.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteDaemonSet}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Excluindo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir DaemonSet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/components/pages/dashboard.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useNodesInfo } from '@/hooks/useKubectl';
|
||||
import {
|
||||
Loader2,
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Monitor,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function Dashboard() {
|
||||
const { nodes, loading, error } = useNodesInfo();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">Carregando informações do cluster...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se não há erro mas também não há nodes, provavelmente não há kubeconfig ativo
|
||||
if (!error && nodes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Bem-vindo ao seu painel de controle
|
||||
</p>
|
||||
</div>
|
||||
<Card className="border-yellow-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-yellow-600 flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
Aguardando configuração
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-yellow-600">
|
||||
Selecione um kubeconfig no menu lateral para visualizar as
|
||||
informações do cluster.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Bem-vindo ao seu painel de controle
|
||||
</p>
|
||||
</div>
|
||||
<Card className="border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600 flex items-center">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
Erro ao carregar dados do cluster
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-500">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Bem-vindo ao seu painel de controle - {nodes.length} node(s)
|
||||
encontrado(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cluster Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total de Nodes
|
||||
</CardTitle>
|
||||
<Server className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{nodes.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nodes ativos no cluster
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Arquiteturas</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{[...new Set(nodes.map((n) => n.architecture))].join(', ')}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tipos de arquitetura
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Kubernetes</CardTitle>
|
||||
<Monitor className="h-4 w-4 text-purple-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{nodes[0]?.kubeletVersion || 'N/A'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Versão do Kubernetes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">OS</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">
|
||||
{nodes[0]?.operatingSystem || 'N/A'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{nodes[0]?.osImage || 'Sistema operacional'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Nodes Details */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Detalhes dos Nodes</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{nodes.map((node, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
{node.name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Informações detalhadas do node
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Arquitetura:</span>{' '}
|
||||
{node.architecture}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">OS:</span>{' '}
|
||||
{node.operatingSystem}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Kernel:</span>{' '}
|
||||
{node.kernelVersion}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Runtime:</span>{' '}
|
||||
{node.containerRuntimeVersion}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Kubelet:</span>{' '}
|
||||
{node.kubeletVersion}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Kube-Proxy:</span>{' '}
|
||||
{node.kubeProxyVersion}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">OS Image:</span>{' '}
|
||||
{node.osImage}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Machine ID:</span>
|
||||
<span className="font-mono text-xs ml-1">
|
||||
{node.machineID}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">System UUID:</span>
|
||||
<span className="font-mono text-xs ml-1">
|
||||
{node.systemUUID}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
521
src/components/pages/deployment-create-advanced.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Code,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
FileText,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DeploymentCreateAdvancedPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [yamlContent, setYamlContent] = useState(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-deployment
|
||||
namespace: default
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
containers:
|
||||
- name: my-container
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"`);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!deploymentName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do Deployment é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!yamlContent.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Conteúdo YAML é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic YAML validation
|
||||
if (!yamlContent.includes('apiVersion') || !yamlContent.includes('kind')) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'YAML deve conter apiVersion e kind',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Update namespace in YAML if different
|
||||
let updatedYaml = yamlContent;
|
||||
if (selectedNamespace !== 'default') {
|
||||
updatedYaml = yamlContent.replace(
|
||||
/namespace:\s*\S+/g,
|
||||
`namespace: ${selectedNamespace}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update deployment name in YAML if provided
|
||||
if (deploymentName !== 'my-deployment') {
|
||||
updatedYaml = updatedYaml.replace(
|
||||
/name:\s*my-deployment/g,
|
||||
`name: ${deploymentName}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create temporary file with the deployment spec
|
||||
const tempFileName = `/tmp/deployment-advanced-${deploymentName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${updatedYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Validate YAML first
|
||||
const validateCommand = `kubectl apply --dry-run=client -f "${tempFileName}"`;
|
||||
const validateResult = await executeCommand(validateCommand);
|
||||
|
||||
if (!validateResult.success) {
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
showToast({
|
||||
title: 'Erro de Validação YAML',
|
||||
description: validateResult.error || 'YAML inválido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the deployment
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'Deployment Avançado Criado com Sucesso',
|
||||
description: `Recursos foram criados no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/deployments');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Deployment',
|
||||
description: result.error || 'Falha ao aplicar o YAML',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadTemplate = (template: string) => {
|
||||
switch (template) {
|
||||
case 'basic':
|
||||
setYamlContent(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName || 'my-deployment'}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName || 'my-container'}
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"`);
|
||||
break;
|
||||
case 'with-service':
|
||||
setYamlContent(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName || 'my-deployment'}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName || 'my-container'}
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${deploymentName || 'my-deployment'}-service
|
||||
namespace: ${selectedNamespace}
|
||||
spec:
|
||||
selector:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
type: ClusterIP`);
|
||||
break;
|
||||
case 'with-configmap':
|
||||
setYamlContent(`apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${deploymentName || 'my-deployment'}-config
|
||||
namespace: ${selectedNamespace}
|
||||
data:
|
||||
config.properties: |
|
||||
app.name=${deploymentName || 'my-app'}
|
||||
app.environment=production
|
||||
log.level=info
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName || 'my-deployment'}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName || 'my-app'}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName || 'my-container'}
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /etc/config
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: ${deploymentName || 'my-deployment'}-config`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Code className="h-8 w-8 mr-3 text-gray-600" />
|
||||
Criar Deployment Avançado
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure um deployment com controle total usando YAML
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="deploymentName">Nome do Deployment</Label>
|
||||
<Input
|
||||
id="deploymentName"
|
||||
placeholder="ex: my-advanced-app"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Será aplicado automaticamente no YAML se usar template
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
Templates Rápidos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleLoadTemplate('basic')}
|
||||
className="h-auto p-4 flex flex-col items-start"
|
||||
>
|
||||
<h4 className="font-medium">Deployment Básico</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Deployment simples com nginx
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleLoadTemplate('with-service')}
|
||||
className="h-auto p-4 flex flex-col items-start"
|
||||
>
|
||||
<h4 className="font-medium">Com Service</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Deployment + Service ClusterIP
|
||||
</p>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleLoadTemplate('with-configmap')}
|
||||
className="h-auto p-4 flex flex-col items-start"
|
||||
>
|
||||
<h4 className="font-medium">Com ConfigMap</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Deployment + ConfigMap + Volume
|
||||
</p>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* YAML Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Code className="h-5 w-5 mr-2" />
|
||||
Editor YAML
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? <EyeOff className="h-4 w-4 mr-2" /> : <Eye className="h-4 w-4 mr-2" />}
|
||||
{showPreview ? 'Ocultar Preview' : 'Mostrar Preview'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="yamlContent">Manifesto YAML</Label>
|
||||
<Textarea
|
||||
id="yamlContent"
|
||||
placeholder="Cole ou edite seu YAML aqui..."
|
||||
value={yamlContent}
|
||||
onChange={(e) => setYamlContent(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suporte completo para múltiplos recursos separados por '---'
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div>
|
||||
<Label>Preview do YAML</Label>
|
||||
<div className="p-4 bg-gray-50 rounded-md border">
|
||||
<pre className="text-xs text-gray-700 whitespace-pre-wrap overflow-auto max-h-[300px]">
|
||||
{yamlContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recursos Avançados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>✓ Controle Total:</strong> Edição completa do manifesto YAML</p>
|
||||
<p><strong>✓ Múltiplos Recursos:</strong> Suporte para Deployment, Service, ConfigMap, etc.</p>
|
||||
<p><strong>✓ Validação:</strong> Verificação automática antes da aplicação</p>
|
||||
<p><strong>✓ Templates:</strong> Pontos de partida para configurações comuns</p>
|
||||
<p><strong>✓ Preview:</strong> Visualização do YAML antes da criação</p>
|
||||
<p><strong>✓ Namespace Automático:</strong> Aplicação do namespace selecionado</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={creating || !deploymentName.trim() || !yamlContent.trim()}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Aplicando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Aplicar YAML
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreateAdvancedPage;
|
||||
490
src/components/pages/deployment-create-api.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Image,
|
||||
Network,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface EnvVar {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function DeploymentCreateApiPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [containerImage, setContainerImage] = useState('');
|
||||
const [replicas, setReplicas] = useState(3);
|
||||
const [containerPort, setContainerPort] = useState(8080);
|
||||
const [servicePort, setServicePort] = useState(80);
|
||||
const [healthPath, setHealthPath] = useState('/health');
|
||||
const [readinessPath, setReadinessPath] = useState('/ready');
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([
|
||||
{ id: '1', name: 'NODE_ENV', value: 'production' },
|
||||
{ id: '2', name: 'PORT', value: '8080' },
|
||||
]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const addEnvVar = () => {
|
||||
const newEnvVar: EnvVar = {
|
||||
id: Date.now().toString(),
|
||||
name: '',
|
||||
value: '',
|
||||
};
|
||||
setEnvVars([...envVars, newEnvVar]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (envId: string) => {
|
||||
setEnvVars(envVars.filter((env) => env.id !== envId));
|
||||
};
|
||||
|
||||
const updateEnvVar = (envId: string, updates: Partial<EnvVar>) => {
|
||||
setEnvVars(envVars.map((env) =>
|
||||
env.id === envId ? { ...env, ...updates } : env
|
||||
));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!deploymentName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do Deployment é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!containerImage.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Imagem do container é obrigatória',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Build environment variables
|
||||
const envVarsYaml = envVars
|
||||
.filter(env => env.name.trim() && env.value.trim())
|
||||
.map(env => ` - name: ${env.name}\n value: "${env.value}"`)
|
||||
.join('\n');
|
||||
|
||||
const deploymentYaml = `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: api
|
||||
spec:
|
||||
replicas: ${replicas}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: api
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName}
|
||||
image: ${containerImage}
|
||||
ports:
|
||||
- containerPort: ${containerPort}
|
||||
name: http
|
||||
env:
|
||||
${envVarsYaml}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: ${healthPath}
|
||||
port: ${containerPort}
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: ${readinessPath}
|
||||
port: ${containerPort}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${deploymentName}-service
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: ${servicePort}
|
||||
targetPort: ${containerPort}
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: ${deploymentName}`;
|
||||
|
||||
// Create temporary file with the deployment spec
|
||||
const tempFileName = `/tmp/deployment-api-${deploymentName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${deploymentYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Apply the deployment
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'API/Microservice Criado com Sucesso',
|
||||
description: `Deployment e Service "${deploymentName}" foram criados no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/deployments');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar API/Microservice',
|
||||
description: result.error || 'Falha ao criar o Deployment',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Settings className="h-8 w-8 mr-3 text-purple-600" />
|
||||
Criar API/Microservice
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure uma API REST com configurações de produção
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="deploymentName">Nome da API</Label>
|
||||
<Input
|
||||
id="deploymentName"
|
||||
placeholder="ex: user-api, payment-service"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Image className="h-5 w-5 mr-2" />
|
||||
Configuração do Container
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="containerImage">Imagem do Container</Label>
|
||||
<Input
|
||||
id="containerImage"
|
||||
placeholder="ex: node:18-alpine, python:3.11-slim"
|
||||
value={containerImage}
|
||||
onChange={(e) => setContainerImage(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Recomendado: node, python, java, golang, php para APIs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="replicas">Número de Réplicas</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={replicas}
|
||||
onChange={(e) => setReplicas(parseInt(e.target.value) || 3)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="containerPort">Porta do Container</Label>
|
||||
<Input
|
||||
id="containerPort"
|
||||
type="number"
|
||||
placeholder="8080"
|
||||
value={containerPort}
|
||||
onChange={(e) => setContainerPort(parseInt(e.target.value) || 8080)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="servicePort">Porta do Service</Label>
|
||||
<Input
|
||||
id="servicePort"
|
||||
type="number"
|
||||
placeholder="80"
|
||||
value={servicePort}
|
||||
onChange={(e) => setServicePort(parseInt(e.target.value) || 80)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Health Checks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Network className="h-5 w-5 mr-2" />
|
||||
Health Checks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="healthPath">Liveness Probe Path</Label>
|
||||
<Input
|
||||
id="healthPath"
|
||||
placeholder="/health"
|
||||
value={healthPath}
|
||||
onChange={(e) => setHealthPath(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Endpoint para verificar se a aplicação está viva
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="readinessPath">Readiness Probe Path</Label>
|
||||
<Input
|
||||
id="readinessPath"
|
||||
placeholder="/ready"
|
||||
value={readinessPath}
|
||||
onChange={(e) => setReadinessPath(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Endpoint para verificar se a aplicação está pronta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Variáveis de Ambiente
|
||||
</div>
|
||||
<Button onClick={addEnvVar} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{envVars.map((envVar, index) => (
|
||||
<Card key={envVar.id} className="border-gray-200">
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||
<div>
|
||||
<Label>Nome</Label>
|
||||
<Input
|
||||
placeholder="ex: API_KEY"
|
||||
value={envVar.name}
|
||||
onChange={(e) => updateEnvVar(envVar.id, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Valor</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="ex: my-secret-key"
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(envVar.id, { value: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(envVar.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Características da API</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>✓ Múltiplas Réplicas:</strong> {replicas} instâncias para alta disponibilidade</p>
|
||||
<p><strong>✓ Health Checks Avançados:</strong> Liveness e Readiness probes configurados</p>
|
||||
<p><strong>✓ Rolling Updates:</strong> Atualizações sem downtime</p>
|
||||
<p><strong>✓ Service ClusterIP:</strong> Comunicação interna otimizada</p>
|
||||
<p><strong>✓ Recursos de Produção:</strong> CPU 200m-500m, Memory 256Mi-512Mi</p>
|
||||
<p><strong>✓ Variáveis de Ambiente:</strong> Configuração flexível</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={creating || !deploymentName.trim() || !containerImage.trim()}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar API/Microservice
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreateApiPage;
|
||||
492
src/components/pages/deployment-create-database.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Database,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Image,
|
||||
HardDrive,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DeploymentCreateDatabasePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [containerImage, setContainerImage] = useState('');
|
||||
const [containerPort, setContainerPort] = useState(5432);
|
||||
const [storageSize, setStorageSize] = useState('1Gi');
|
||||
const [databaseName, setDatabaseName] = useState('');
|
||||
const [databaseUser, setDatabaseUser] = useState('');
|
||||
const [databasePassword, setDatabasePassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!deploymentName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do Deployment é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!containerImage.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Imagem do container é obrigatória',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!databaseName.trim() || !databaseUser.trim() || !databasePassword.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Configurações do banco de dados são obrigatórias',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const deploymentYaml = `apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${deploymentName}-secret
|
||||
namespace: ${selectedNamespace}
|
||||
type: Opaque
|
||||
data:
|
||||
POSTGRES_DB: ${btoa(databaseName)}
|
||||
POSTGRES_USER: ${btoa(databaseUser)}
|
||||
POSTGRES_PASSWORD: ${btoa(databasePassword)}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ${deploymentName}-pvc
|
||||
namespace: ${selectedNamespace}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: ${storageSize}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: database
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: database
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName}
|
||||
image: ${containerImage}
|
||||
ports:
|
||||
- containerPort: ${containerPort}
|
||||
name: db
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${deploymentName}-secret
|
||||
key: POSTGRES_DB
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${deploymentName}-secret
|
||||
key: POSTGRES_USER
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${deploymentName}-secret
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: postgres-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- \${POSTGRES_USER}
|
||||
- -d
|
||||
- \${POSTGRES_DB}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- \${POSTGRES_USER}
|
||||
- -d
|
||||
- \${POSTGRES_DB}
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
volumes:
|
||||
- name: postgres-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: ${deploymentName}-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${deploymentName}-service
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: database
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: ${containerPort}
|
||||
targetPort: ${containerPort}
|
||||
protocol: TCP
|
||||
name: db
|
||||
selector:
|
||||
app: ${deploymentName}`;
|
||||
|
||||
// Create temporary file with the deployment spec
|
||||
const tempFileName = `/tmp/deployment-database-${deploymentName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${deploymentYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Apply the deployment
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'Aplicação com Banco Criada com Sucesso',
|
||||
description: `Deployment, PVC, Secret e Service "${deploymentName}" foram criados no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/deployments');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Aplicação com Banco',
|
||||
description: result.error || 'Falha ao criar o Deployment',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Database className="h-8 w-8 mr-3 text-orange-600" />
|
||||
Criar Aplicação com Banco
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure uma aplicação com banco de dados e volumes persistentes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="deploymentName">Nome da Aplicação</Label>
|
||||
<Input
|
||||
id="deploymentName"
|
||||
placeholder="ex: my-postgres, redis-cache"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Image className="h-5 w-5 mr-2" />
|
||||
Configuração do Container
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="containerImage">Imagem do Container</Label>
|
||||
<Input
|
||||
id="containerImage"
|
||||
placeholder="ex: postgres:15, mysql:8.0, redis:7-alpine"
|
||||
value={containerImage}
|
||||
onChange={(e) => setContainerImage(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Recomendado: postgres, mysql, redis, mongodb
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="containerPort">Porta do Container</Label>
|
||||
<Select
|
||||
value={containerPort.toString()}
|
||||
onValueChange={(value) => setContainerPort(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5432">5432 (PostgreSQL)</SelectItem>
|
||||
<SelectItem value="3306">3306 (MySQL)</SelectItem>
|
||||
<SelectItem value="6379">6379 (Redis)</SelectItem>
|
||||
<SelectItem value="27017">27017 (MongoDB)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="storageSize">Tamanho do Armazenamento</Label>
|
||||
<Select
|
||||
value={storageSize}
|
||||
onValueChange={setStorageSize}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1Gi">1Gi</SelectItem>
|
||||
<SelectItem value="5Gi">5Gi</SelectItem>
|
||||
<SelectItem value="10Gi">10Gi</SelectItem>
|
||||
<SelectItem value="20Gi">20Gi</SelectItem>
|
||||
<SelectItem value="50Gi">50Gi</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Key className="h-5 w-5 mr-2" />
|
||||
Configuração do Banco de Dados
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="databaseName">Nome do Banco</Label>
|
||||
<Input
|
||||
id="databaseName"
|
||||
placeholder="ex: myapp_db"
|
||||
value={databaseName}
|
||||
onChange={(e) => setDatabaseName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="databaseUser">Usuário</Label>
|
||||
<Input
|
||||
id="databaseUser"
|
||||
placeholder="ex: myapp_user"
|
||||
value={databaseUser}
|
||||
onChange={(e) => setDatabaseUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="databasePassword">Senha</Label>
|
||||
<Input
|
||||
id="databasePassword"
|
||||
type="password"
|
||||
placeholder="Senha segura"
|
||||
value={databasePassword}
|
||||
onChange={(e) => setDatabasePassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-50 rounded-md border border-orange-200">
|
||||
<p className="text-sm text-orange-700">
|
||||
<strong>🔐 Segurança:</strong> As credenciais serão armazenadas em um Secret do Kubernetes
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Configuração de Armazenamento
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>📦 Volume Persistente:</strong> {storageSize} será criado automaticamente
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Os dados do banco serão preservados mesmo se o pod for reiniciado
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recursos Incluídos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>✓ Secret:</strong> Credenciais seguras do banco de dados</p>
|
||||
<p><strong>✓ PersistentVolumeClaim:</strong> Armazenamento persistente de {storageSize}</p>
|
||||
<p><strong>✓ Deployment:</strong> Uma réplica com estratégia Recreate</p>
|
||||
<p><strong>✓ Service:</strong> ClusterIP para comunicação interna</p>
|
||||
<p><strong>✓ Health Checks:</strong> Probes específicos para banco de dados</p>
|
||||
<p><strong>✓ Recursos:</strong> CPU 250m-500m, Memory 512Mi-1Gi</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={creating || !deploymentName.trim() || !containerImage.trim() || !databaseName.trim()}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar Aplicação com Banco
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreateDatabasePage;
|
||||
628
src/components/pages/deployment-create-simple.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo, useSecretsInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Container,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Image,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DeploymentCreateSimplePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { secrets } = useSecretsInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [containerImage, setContainerImage] = useState('');
|
||||
const [replicas, setReplicas] = useState(1);
|
||||
const [containerPort, setContainerPort] = useState(80);
|
||||
const [selectedImagePullSecret, setSelectedImagePullSecret] = useState(() => {
|
||||
return localStorage.getItem('deployment-simple-imagePullSecret') || 'none';
|
||||
});
|
||||
const [cpuRequest, setCpuRequest] = useState('50m');
|
||||
const [cpuLimit, setCpuLimit] = useState('100m');
|
||||
const [memoryRequest, setMemoryRequest] = useState('64Mi');
|
||||
const [memoryLimit, setMemoryLimit] = useState('128Mi');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Persistir a escolha do Image Pull Secret
|
||||
React.useEffect(() => {
|
||||
if (selectedImagePullSecret !== 'none') {
|
||||
localStorage.setItem('deployment-simple-imagePullSecret', selectedImagePullSecret);
|
||||
} else {
|
||||
localStorage.removeItem('deployment-simple-imagePullSecret');
|
||||
}
|
||||
}, [selectedImagePullSecret]);
|
||||
|
||||
// Filtrar secrets de registry do namespace selecionado
|
||||
const registrySecrets = React.useMemo(() => {
|
||||
return secrets.filter(secret =>
|
||||
secret.namespace === selectedNamespace &&
|
||||
(secret.type === 'kubernetes.io/dockerconfigjson' || secret.type === 'kubernetes.io/dockercfg')
|
||||
);
|
||||
}, [secrets, selectedNamespace]);
|
||||
|
||||
// Resetar secret selecionado se não existir no novo namespace
|
||||
React.useEffect(() => {
|
||||
if (selectedImagePullSecret !== 'none') {
|
||||
const secretExists = registrySecrets.some(secret => secret.name === selectedImagePullSecret);
|
||||
if (!secretExists) {
|
||||
setSelectedImagePullSecret('none');
|
||||
}
|
||||
}
|
||||
}, [selectedNamespace, registrySecrets, selectedImagePullSecret]);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!deploymentName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do Deployment é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!containerImage.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Imagem do container é obrigatória',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (replicas < 1 || replicas > 10) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Número de réplicas deve estar entre 1 e 10',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (containerPort <= 0 || containerPort > 65535) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Porta do container deve estar entre 1 e 65535',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate resource values
|
||||
if (!cpuRequest.trim() || !cpuLimit.trim() || !memoryRequest.trim() || !memoryLimit.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Todos os campos de recursos são obrigatórios',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic format validation for CPU (should contain 'm' or be a decimal)
|
||||
const cpuRequestValid = /^(\d+m|\d*\.?\d+)$/.test(cpuRequest);
|
||||
const cpuLimitValid = /^(\d+m|\d*\.?\d+)$/.test(cpuLimit);
|
||||
|
||||
if (!cpuRequestValid || !cpuLimitValid) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Formato de CPU inválido. Use: 100m, 0.1, 1, etc.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic format validation for Memory (should contain Mi, Gi, etc.)
|
||||
const memoryRequestValid = /^\d+(Mi|Gi|Ki|M|G|K)$/i.test(memoryRequest);
|
||||
const memoryLimitValid = /^\d+(Mi|Gi|Ki|M|G|K)$/i.test(memoryLimit);
|
||||
|
||||
if (!memoryRequestValid || !memoryLimitValid) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Formato de memória inválido. Use: 64Mi, 1Gi, 512Mi, etc.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Build imagePullSecrets section
|
||||
const imagePullSecretsYaml = selectedImagePullSecret !== 'none'
|
||||
? ` imagePullSecrets:
|
||||
- name: ${selectedImagePullSecret}`
|
||||
: '';
|
||||
|
||||
const deploymentYaml = `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
spec:
|
||||
replicas: ${replicas}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
spec:
|
||||
${imagePullSecretsYaml}
|
||||
containers:
|
||||
- name: ${deploymentName}
|
||||
image: ${containerImage}
|
||||
ports:
|
||||
- containerPort: ${containerPort}
|
||||
resources:
|
||||
requests:
|
||||
memory: "${memoryRequest}"
|
||||
cpu: "${cpuRequest}"
|
||||
limits:
|
||||
memory: "${memoryLimit}"
|
||||
cpu: "${cpuLimit}"`;
|
||||
|
||||
// Create temporary file with the deployment spec
|
||||
const tempFileName = `/tmp/deployment-simple-${deploymentName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${deploymentYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Apply the deployment
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'Deployment Criado com Sucesso',
|
||||
description: `Deployment "${deploymentName}" foi criado no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/deployments');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Deployment',
|
||||
description: result.error || 'Falha ao criar o Deployment',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Container className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Criar Deployment Simples
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure uma aplicação básica com configuração mínima
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="deploymentName">Nome do Deployment</Label>
|
||||
<Input
|
||||
id="deploymentName"
|
||||
placeholder="ex: my-app"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Image className="h-5 w-5 mr-2" />
|
||||
Configuração do Container
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="containerImage">Imagem do Container</Label>
|
||||
<Input
|
||||
id="containerImage"
|
||||
placeholder="ex: nginx:latest"
|
||||
value={containerImage}
|
||||
onChange={(e) => setContainerImage(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Exemplo: nginx:latest, httpd:alpine, node:18-alpine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="replicas">Número de Réplicas</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={replicas}
|
||||
onChange={(e) => setReplicas(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="containerPort">Porta do Container</Label>
|
||||
<Input
|
||||
id="containerPort"
|
||||
type="number"
|
||||
placeholder="80"
|
||||
value={containerPort}
|
||||
onChange={(e) => setContainerPort(parseInt(e.target.value) || 80)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Image Pull Secret */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Key className="h-5 w-5 mr-2" />
|
||||
Image Pull Secret
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="imagePullSecret">Secret para Registry Privado</Label>
|
||||
<Select
|
||||
value={selectedImagePullSecret}
|
||||
onValueChange={setSelectedImagePullSecret}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um secret" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<div className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Nenhum (imagem pública)
|
||||
</div>
|
||||
</SelectItem>
|
||||
{registrySecrets.map((secret) => (
|
||||
<SelectItem key={secret.name} value={secret.name}>
|
||||
<div className="flex items-center">
|
||||
<Key className="h-3 w-3 mr-2" />
|
||||
{secret.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{selectedImagePullSecret === 'none'
|
||||
? 'Usando imagem pública - nenhuma autenticação necessária'
|
||||
: registrySecrets.length === 0
|
||||
? 'Nenhum secret de registry encontrado no namespace selecionado'
|
||||
: `Secret selecionado: ${selectedImagePullSecret}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedImagePullSecret !== 'none' && (
|
||||
<div className="p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>🔐 Registry Privado:</strong> Usando secret "{selectedImagePullSecret}" para autenticação
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Este secret será usado para fazer pull de imagens de registries privados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrySecrets.length === 0 && (
|
||||
<div className="p-3 bg-yellow-50 rounded-md border border-yellow-200">
|
||||
<p className="text-sm text-yellow-700">
|
||||
<strong>ℹ️ Dica:</strong> Nenhum secret de registry encontrado no namespace "{selectedNamespace}"
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600 mt-1">
|
||||
Para usar imagens privadas, crie um secret do tipo docker-registry primeiro
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resources Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Configuração de Recursos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Resource Presets */}
|
||||
<div>
|
||||
<Label>Templates de Recursos</Label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCpuRequest('50m');
|
||||
setCpuLimit('100m');
|
||||
setMemoryRequest('64Mi');
|
||||
setMemoryLimit('128Mi');
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
💼 Micro (Padrão)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCpuRequest('100m');
|
||||
setCpuLimit('200m');
|
||||
setMemoryRequest('128Mi');
|
||||
setMemoryLimit('256Mi');
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
📱 Pequeno
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCpuRequest('250m');
|
||||
setCpuLimit('500m');
|
||||
setMemoryRequest('256Mi');
|
||||
setMemoryLimit('512Mi');
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
💻 Médio
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCpuRequest('500m');
|
||||
setCpuLimit('1');
|
||||
setMemoryRequest('512Mi');
|
||||
setMemoryLimit('1Gi');
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
🚀 Grande
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* CPU Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm flex items-center">
|
||||
<span className="text-blue-600 mr-2">⚡</span>
|
||||
CPU
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="cpuRequest">CPU Request</Label>
|
||||
<Input
|
||||
id="cpuRequest"
|
||||
placeholder="ex: 50m, 0.1, 100m"
|
||||
value={cpuRequest}
|
||||
onChange={(e) => setCpuRequest(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mínimo garantido de CPU
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cpuLimit">CPU Limit</Label>
|
||||
<Input
|
||||
id="cpuLimit"
|
||||
placeholder="ex: 100m, 0.2, 200m"
|
||||
value={cpuLimit}
|
||||
onChange={(e) => setCpuLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Máximo permitido de CPU
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm flex items-center">
|
||||
<span className="text-green-600 mr-2">🧠</span>
|
||||
Memory
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="memoryRequest">Memory Request</Label>
|
||||
<Input
|
||||
id="memoryRequest"
|
||||
placeholder="ex: 64Mi, 128Mi, 256Mi"
|
||||
value={memoryRequest}
|
||||
onChange={(e) => setMemoryRequest(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mínimo garantido de memória
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="memoryLimit">Memory Limit</Label>
|
||||
<Input
|
||||
id="memoryLimit"
|
||||
placeholder="ex: 128Mi, 256Mi, 512Mi"
|
||||
value={memoryLimit}
|
||||
onChange={(e) => setMemoryLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Máximo permitido de memória
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources Summary */}
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-md border">
|
||||
<h4 className="font-medium text-sm mb-3">Resumo dos Recursos</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p><strong>CPU Request:</strong> {cpuRequest} per pod</p>
|
||||
<p><strong>CPU Limit:</strong> {cpuLimit} per pod</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p><strong>Memory Request:</strong> {memoryRequest} per pod</p>
|
||||
<p><strong>Memory Limit:</strong> {memoryLimit} per pod</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<p className="text-sm"><strong>Total Pods:</strong> {replicas}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Total de recursos: CPU {cpuRequest}-{cpuLimit} × {replicas}, Memory {memoryRequest}-{memoryLimit} × {replicas}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Guidelines */}
|
||||
<div className="p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>💡 Dicas de Recursos:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-blue-600 mt-1 space-y-1">
|
||||
<li>• CPU: Use 'm' para milicores (ex: 100m = 0.1 core)</li>
|
||||
<li>• Memory: Use Mi/Gi para mebibytes/gibibytes (ex: 128Mi, 1Gi)</li>
|
||||
<li>• Requests: Recursos garantidos (afeta scheduling)</li>
|
||||
<li>• Limits: Máximo permitido (evita sobrecarga do nó)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={creating || !deploymentName.trim() || !containerImage.trim()}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar Deployment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreateSimplePage;
|
||||
364
src/components/pages/deployment-create-webapp.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Image,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DeploymentCreateWebappPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [containerImage, setContainerImage] = useState('');
|
||||
const [replicas, setReplicas] = useState(2);
|
||||
const [containerPort, setContainerPort] = useState(8080);
|
||||
const [servicePort, setServicePort] = useState(80);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!deploymentName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do Deployment é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!containerImage.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Imagem do container é obrigatória',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const deploymentYaml = `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
spec:
|
||||
replicas: ${replicas}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName}
|
||||
image: ${containerImage}
|
||||
ports:
|
||||
- containerPort: ${containerPort}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: ${containerPort}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: ${containerPort}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${deploymentName}-service
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: ${servicePort}
|
||||
targetPort: ${containerPort}
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: ${deploymentName}`;
|
||||
|
||||
// Create temporary file with the deployment spec
|
||||
const tempFileName = `/tmp/deployment-webapp-${deploymentName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${deploymentYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Apply the deployment
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'Aplicação Web Criada com Sucesso',
|
||||
description: `Deployment e Service "${deploymentName}" foram criados no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/deployments');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Aplicação Web',
|
||||
description: result.error || 'Falha ao criar o Deployment',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Zap className="h-8 w-8 mr-3 text-green-600" />
|
||||
Criar Aplicação Web
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure uma aplicação web com Service e health checks
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="deploymentName">Nome da Aplicação</Label>
|
||||
<Input
|
||||
id="deploymentName"
|
||||
placeholder="ex: my-webapp"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Image className="h-5 w-5 mr-2" />
|
||||
Configuração do Container
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="containerImage">Imagem do Container</Label>
|
||||
<Input
|
||||
id="containerImage"
|
||||
placeholder="ex: nginx:latest, node:18-alpine"
|
||||
value={containerImage}
|
||||
onChange={(e) => setContainerImage(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Recomendado: nginx, httpd, node, php, python web frameworks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="replicas">Número de Réplicas</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={replicas}
|
||||
onChange={(e) => setReplicas(parseInt(e.target.value) || 2)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="containerPort">Porta do Container</Label>
|
||||
<Input
|
||||
id="containerPort"
|
||||
type="number"
|
||||
placeholder="8080"
|
||||
value={containerPort}
|
||||
onChange={(e) => setContainerPort(parseInt(e.target.value) || 8080)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="servicePort">Porta do Service</Label>
|
||||
<Input
|
||||
id="servicePort"
|
||||
type="number"
|
||||
placeholder="80"
|
||||
value={servicePort}
|
||||
onChange={(e) => setServicePort(parseInt(e.target.value) || 80)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Service Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Network className="h-5 w-5 mr-2" />
|
||||
Configuração do Service
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-3 bg-green-50 rounded-md border border-green-200">
|
||||
<p className="text-sm text-green-700">
|
||||
<strong>Service incluído:</strong> {deploymentName || '<app-name>'}-service
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Tipo: ClusterIP | Porta: {servicePort} → {containerPort}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Características da Aplicação Web</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>✓ Health Checks:</strong> Liveness e Readiness probes configurados</p>
|
||||
<p><strong>✓ Service:</strong> ClusterIP automático para comunicação interna</p>
|
||||
<p><strong>✓ Recursos:</strong> CPU 100m-200m, Memory 128Mi-256Mi por pod</p>
|
||||
<p><strong>✓ Alta Disponibilidade:</strong> {replicas} réplicas por padrão</p>
|
||||
<p><strong>✓ Load Balancing:</strong> Distribuição automática de tráfego</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={creating || !deploymentName.trim() || !containerImage.trim()}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar Aplicação Web
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreateWebappPage;
|
||||
492
src/components/pages/deployment-create-worker.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Layers,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Image,
|
||||
Zap,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface EnvVar {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function DeploymentCreateWorkerPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [containerImage, setContainerImage] = useState('');
|
||||
const [replicas, setReplicas] = useState(1);
|
||||
const [minReplicas, setMinReplicas] = useState(1);
|
||||
const [maxReplicas, setMaxReplicas] = useState(10);
|
||||
const [cpuThreshold, setCpuThreshold] = useState(70);
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([
|
||||
{ id: '1', name: 'WORKER_TYPE', value: 'background' },
|
||||
{ id: '2', name: 'QUEUE_NAME', value: 'default' },
|
||||
]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const addEnvVar = () => {
|
||||
const newEnvVar: EnvVar = {
|
||||
id: Date.now().toString(),
|
||||
name: '',
|
||||
value: '',
|
||||
};
|
||||
setEnvVars([...envVars, newEnvVar]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (envId: string) => {
|
||||
setEnvVars(envVars.filter((env) => env.id !== envId));
|
||||
};
|
||||
|
||||
const updateEnvVar = (envId: string, updates: Partial<EnvVar>) => {
|
||||
setEnvVars(envVars.map((env) =>
|
||||
env.id === envId ? { ...env, ...updates } : env
|
||||
));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!deploymentName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do Deployment é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!containerImage.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Imagem do container é obrigatória',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Build environment variables
|
||||
const envVarsYaml = envVars
|
||||
.filter(env => env.name.trim() && env.value.trim())
|
||||
.map(env => ` - name: ${env.name}\n value: "${env.value}"`)
|
||||
.join('\n');
|
||||
|
||||
const deploymentYaml = `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName}
|
||||
namespace: ${selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: worker
|
||||
spec:
|
||||
replicas: ${replicas}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
tier: worker
|
||||
spec:
|
||||
containers:
|
||||
- name: ${deploymentName}
|
||||
image: ${containerImage}
|
||||
env:
|
||||
${envVarsYaml}
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pgrep
|
||||
- -f
|
||||
- ${deploymentName}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: Always
|
||||
restartPolicy: Always
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: ${deploymentName}-hpa
|
||||
namespace: ${selectedNamespace}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: ${deploymentName}
|
||||
minReplicas: ${minReplicas}
|
||||
maxReplicas: ${maxReplicas}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: ${cpuThreshold}
|
||||
behavior:
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 60
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 15
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 10
|
||||
periodSeconds: 60`;
|
||||
|
||||
// Create temporary file with the deployment spec
|
||||
const tempFileName = `/tmp/deployment-worker-${deploymentName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${deploymentYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Apply the deployment
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'Worker/Background Criado com Sucesso',
|
||||
description: `Deployment e HPA "${deploymentName}" foram criados no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/deployments');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Worker/Background',
|
||||
description: result.error || 'Falha ao criar o Deployment',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Layers className="h-8 w-8 mr-3 text-red-600" />
|
||||
Criar Worker/Background
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure um worker para processamento em background e filas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="deploymentName">Nome do Worker</Label>
|
||||
<Input
|
||||
id="deploymentName"
|
||||
placeholder="ex: queue-worker, background-processor"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Image className="h-5 w-5 mr-2" />
|
||||
Configuração do Container
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="containerImage">Imagem do Container</Label>
|
||||
<Input
|
||||
id="containerImage"
|
||||
placeholder="ex: python:3.11-slim, node:18-alpine"
|
||||
value={containerImage}
|
||||
onChange={(e) => setContainerImage(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Recomendado: python, node, java, golang para workers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="replicas">Réplicas Iniciais</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={replicas}
|
||||
onChange={(e) => setReplicas(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auto Scaling Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
Configuração de Auto-Scaling
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="minReplicas">Mínimo de Réplicas</Label>
|
||||
<Input
|
||||
id="minReplicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={minReplicas}
|
||||
onChange={(e) => setMinReplicas(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxReplicas">Máximo de Réplicas</Label>
|
||||
<Input
|
||||
id="maxReplicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={maxReplicas}
|
||||
onChange={(e) => setMaxReplicas(parseInt(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cpuThreshold">Limite de CPU (%)</Label>
|
||||
<Input
|
||||
id="cpuThreshold"
|
||||
type="number"
|
||||
min="10"
|
||||
max="100"
|
||||
value={cpuThreshold}
|
||||
onChange={(e) => setCpuThreshold(parseInt(e.target.value) || 70)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 rounded-md border border-red-200">
|
||||
<p className="text-sm text-red-700">
|
||||
<strong>⚡ Auto-scaling:</strong> Escala automaticamente entre {minReplicas} e {maxReplicas} réplicas
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
Trigger: Quando CPU {'>'} {cpuThreshold}% | Cooldown: 60s para scale-up, 5min para scale-down
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Variáveis de Ambiente
|
||||
</div>
|
||||
<Button onClick={addEnvVar} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{envVars.map((envVar, index) => (
|
||||
<Card key={envVar.id} className="border-gray-200">
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||
<div>
|
||||
<Label>Nome</Label>
|
||||
<Input
|
||||
placeholder="ex: REDIS_URL"
|
||||
value={envVar.name}
|
||||
onChange={(e) => updateEnvVar(envVar.id, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Valor</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="ex: redis://redis:6379"
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(envVar.id, { value: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(envVar.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Características do Worker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>✓ Sem Service:</strong> Worker não precisa de comunicação HTTP</p>
|
||||
<p><strong>✓ Auto-scaling:</strong> HPA baseado em CPU para alta demanda</p>
|
||||
<p><strong>✓ Tolerância a Falhas:</strong> Rolling updates sem interrupção</p>
|
||||
<p><strong>✓ Health Checks:</strong> Liveness probe para detectar travamentos</p>
|
||||
<p><strong>✓ Recursos Flexíveis:</strong> CPU 100m-1000m, Memory 128Mi-512Mi</p>
|
||||
<p><strong>✓ Ambiente Configurável:</strong> Variáveis para filas e configurações</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={creating || !deploymentName.trim() || !containerImage.trim()}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar Worker/Background
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreateWorkerPage;
|
||||
177
src/components/pages/deployment-create.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Container,
|
||||
Zap,
|
||||
Settings,
|
||||
Database,
|
||||
Code,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DeploymentCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const deploymentTypes = [
|
||||
{
|
||||
id: 'simple',
|
||||
title: 'Deployment Simples',
|
||||
description: 'Aplicação web básica com configuração mínima',
|
||||
icon: Container,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
features: ['Container único', 'Configuração básica', 'Ideal para começar'],
|
||||
path: '/workloads/deployments/create/simple',
|
||||
},
|
||||
{
|
||||
id: 'webapp',
|
||||
title: 'Aplicação Web',
|
||||
description: 'Aplicação web com Service e configurações otimizadas',
|
||||
icon: Zap,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
features: ['Service incluído', 'Health checks', 'Recursos configurados'],
|
||||
path: '/workloads/deployments/create/webapp',
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
title: 'API/Microservice',
|
||||
description: 'API REST com configurações de produção',
|
||||
icon: Settings,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
borderColor: 'border-purple-200',
|
||||
features: ['Múltiplas réplicas', 'Probes avançados', 'Variáveis de ambiente'],
|
||||
path: '/workloads/deployments/create/api',
|
||||
},
|
||||
{
|
||||
id: 'database',
|
||||
title: 'Aplicação com Banco',
|
||||
description: 'App com banco de dados e volumes persistentes',
|
||||
icon: Database,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
features: ['Volumes persistentes', 'ConfigMaps/Secrets', 'Init containers'],
|
||||
path: '/workloads/deployments/create/database',
|
||||
},
|
||||
{
|
||||
id: 'worker',
|
||||
title: 'Worker/Background',
|
||||
description: 'Processamento em background e filas',
|
||||
icon: Layers,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
features: ['Sem Service', 'Auto-scaling', 'Tolerância a falhas'],
|
||||
path: '/workloads/deployments/create/worker',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: 'Deployment Avançado',
|
||||
description: 'Configuração personalizada completa',
|
||||
icon: Code,
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
features: ['Editor YAML', 'Configuração completa', 'Controle total'],
|
||||
path: '/workloads/deployments/create/advanced',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Container className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Criar Deployment
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Escolha o tipo de Deployment que deseja criar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Selecione o template que melhor atende ao seu caso de uso.
|
||||
Cada opção oferece configurações otimizadas para diferentes tipos de aplicação.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Deployment Type Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{deploymentTypes.map((type) => {
|
||||
const IconComponent = type.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={type.id}
|
||||
className={`cursor-pointer transition-all duration-200 hover:shadow-lg hover:scale-105 ${type.borderColor} ${type.bgColor}`}
|
||||
onClick={() => navigate(type.path)}
|
||||
>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className={`mx-auto mb-4 p-3 rounded-full bg-white shadow-sm w-fit`}>
|
||||
<IconComponent className={`h-8 w-8 ${type.color}`} />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{type.title}</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{type.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm text-gray-700">Características:</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{type.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-center">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${type.color.replace('text-', 'bg-')} mr-2`} />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
variant="outline"
|
||||
onClick={() => navigate(type.path)}
|
||||
>
|
||||
Selecionar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Escolha uma opção acima para continuar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentCreatePage;
|
||||
6307
src/components/pages/deployment-details.tsx
Normal file
764
src/components/pages/deployment-import-ecs.tsx
Normal file
@@ -0,0 +1,764 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Cloud,
|
||||
FileText,
|
||||
Upload,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ECSTaskDefinition {
|
||||
family: string;
|
||||
containerDefinitions: Array<{
|
||||
name: string;
|
||||
image: string;
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
portMappings?: Array<{
|
||||
containerPort: number;
|
||||
hostPort?: number;
|
||||
protocol?: string;
|
||||
}>;
|
||||
environment?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
secrets?: Array<{
|
||||
name: string;
|
||||
valueFrom: string;
|
||||
}>;
|
||||
essential?: boolean;
|
||||
}>;
|
||||
cpu?: string;
|
||||
memory?: string;
|
||||
networkMode?: string;
|
||||
requiresCompatibilities?: string[];
|
||||
}
|
||||
|
||||
export function DeploymentImportECSPage() {
|
||||
const navigate = useNavigate();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { showToast } = useToast();
|
||||
const { selectedNamespace, setSelectedNamespace } = useGlobalNamespace();
|
||||
const { namespaces: namespacesData, loading: namespacesLoading } =
|
||||
useNamespacesInfo();
|
||||
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [deploymentName, setDeploymentName] = useState('');
|
||||
const [replicas, setReplicas] = useState(() => {
|
||||
// Recupera o valor salvo ou usa '1' como padrão
|
||||
return localStorage.getItem('ecs-import-replicas') || '1';
|
||||
});
|
||||
const [parsedConfig, setParsedConfig] = useState<ECSTaskDefinition | null>(
|
||||
null,
|
||||
);
|
||||
const [parseError, setParseError] = useState('');
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [createService, setCreateService] = useState(() => {
|
||||
const saved = localStorage.getItem('ecs-import-create-service');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const [createConfigMap, setCreateConfigMap] = useState(() => {
|
||||
const saved = localStorage.getItem('ecs-import-create-configmap');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const [createSecrets, setCreateSecrets] = useState(() => {
|
||||
const saved = localStorage.getItem('ecs-import-create-secrets');
|
||||
return saved ? saved === 'true' : true;
|
||||
});
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Get namespace names from the namespace objects
|
||||
const namespaces = namespacesData.map((ns) => ns.name).sort();
|
||||
|
||||
// Salva as preferências quando mudarem
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ecs-import-replicas', replicas);
|
||||
}, [replicas]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ecs-import-create-service', createService.toString());
|
||||
}, [createService]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(
|
||||
'ecs-import-create-configmap',
|
||||
createConfigMap.toString(),
|
||||
);
|
||||
}, [createConfigMap]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ecs-import-create-secrets', createSecrets.toString());
|
||||
}, [createSecrets]);
|
||||
|
||||
// Function to sanitize names for Kubernetes resources
|
||||
const sanitizeKubernetesName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens
|
||||
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||
.replace(/-+/g, '-'); // Replace multiple hyphens with single
|
||||
};
|
||||
|
||||
const handleParseJSON = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput);
|
||||
setParsedConfig(parsed);
|
||||
setParseError('');
|
||||
|
||||
// Auto-fill deployment name from family
|
||||
if (parsed.family && !deploymentName) {
|
||||
setDeploymentName(sanitizeKubernetesName(parsed.family));
|
||||
}
|
||||
} catch (error) {
|
||||
setParseError('JSON inválido. Verifique a sintaxe.');
|
||||
setParsedConfig(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||
showToast({
|
||||
title: 'Arquivo Inválido',
|
||||
description: 'Por favor, selecione um arquivo .json',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setJsonInput(content);
|
||||
|
||||
// Auto-parse the JSON
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
setParsedConfig(parsed);
|
||||
setParseError('');
|
||||
|
||||
// Auto-fill deployment name from family
|
||||
if (parsed.family && !deploymentName) {
|
||||
setDeploymentName(sanitizeKubernetesName(parsed.family));
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: 'Arquivo Carregado',
|
||||
description: `Task Definition "${parsed.family}" carregada com sucesso`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
setParseError('JSON inválido no arquivo selecionado.');
|
||||
setParsedConfig(null);
|
||||
showToast({
|
||||
title: 'Erro no Arquivo',
|
||||
description: 'O arquivo selecionado não contém JSON válido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// Reset the input to allow selecting the same file again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const createConfigMapYaml = (ecsConfig: ECSTaskDefinition): string | null => {
|
||||
const container = ecsConfig.containerDefinitions[0];
|
||||
const envVars = container.environment || [];
|
||||
|
||||
if (envVars.length === 0) return null;
|
||||
|
||||
const dataEntries = envVars
|
||||
.map((env) => ` ${env.name}: "${env.value}"`)
|
||||
.join('\n');
|
||||
|
||||
return `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${deploymentName}-config
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
imported-from: aws-ecs
|
||||
data:
|
||||
${dataEntries}`;
|
||||
};
|
||||
|
||||
const createSecretYaml = (ecsConfig: ECSTaskDefinition): string | null => {
|
||||
const container = ecsConfig.containerDefinitions[0];
|
||||
const secrets = container.secrets || [];
|
||||
|
||||
if (secrets.length === 0) return null;
|
||||
|
||||
const dataEntries = secrets
|
||||
.map((secret) => ` ${secret.name}: Y2hhbmdlbWU=`)
|
||||
.join('\n');
|
||||
|
||||
return `apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${deploymentName}-secrets
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
imported-from: aws-ecs
|
||||
type: Opaque
|
||||
data:
|
||||
${dataEntries}`;
|
||||
};
|
||||
|
||||
const createServiceYaml = (ecsConfig: ECSTaskDefinition): string | null => {
|
||||
const container = ecsConfig.containerDefinitions[0];
|
||||
const ports = container.portMappings || [];
|
||||
|
||||
if (ports.length === 0) return null;
|
||||
|
||||
const portEntries = ports
|
||||
.map((port) => {
|
||||
const portName = port.name
|
||||
? sanitizeKubernetesName(port.name)
|
||||
: `port-${port.containerPort}`;
|
||||
|
||||
return ` - name: "${portName}"
|
||||
port: ${port.containerPort}
|
||||
targetPort: ${port.containerPort}
|
||||
protocol: ${port.protocol?.toUpperCase() || 'TCP'}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${deploymentName}-service
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
imported-from: aws-ecs
|
||||
spec:
|
||||
selector:
|
||||
app: ${deploymentName}
|
||||
ports:
|
||||
${portEntries}
|
||||
type: ClusterIP`;
|
||||
};
|
||||
|
||||
const convertECSToKubernetes = (ecsConfig: ECSTaskDefinition): string => {
|
||||
const container = ecsConfig.containerDefinitions[0]; // Assume primeiro container
|
||||
|
||||
// Add secrets if creating them
|
||||
const secretEnvVars: Array<string> = [];
|
||||
const secrets = container.secrets || [];
|
||||
if (createSecrets && secrets.length > 0) {
|
||||
secrets.forEach((secret) => {
|
||||
secretEnvVars.push(` - name: ${secret.name}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${deploymentName}-secrets
|
||||
key: ${secret.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Direct environment variables if not using ConfigMap
|
||||
const directEnvVars: Array<string> = [];
|
||||
if (!createConfigMap && container.environment) {
|
||||
container.environment.forEach((env) => {
|
||||
directEnvVars.push(` - name: ${env.name}
|
||||
value: "${env.value}"`);
|
||||
});
|
||||
}
|
||||
|
||||
const allEnvVars = [...directEnvVars, ...secretEnvVars];
|
||||
const envSection =
|
||||
allEnvVars.length > 0
|
||||
? `
|
||||
env:
|
||||
${allEnvVars.join('\n')}`
|
||||
: '';
|
||||
|
||||
const envFromSection =
|
||||
createConfigMap &&
|
||||
container.environment &&
|
||||
container.environment.length > 0
|
||||
? `
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: ${deploymentName}-config`
|
||||
: '';
|
||||
|
||||
const portsSection =
|
||||
container.portMappings && container.portMappings.length > 0
|
||||
? `
|
||||
ports:
|
||||
${container.portMappings
|
||||
.map(
|
||||
(port) => ` - containerPort: ${port.containerPort}
|
||||
protocol: ${port.protocol?.toUpperCase() || 'TCP'}`,
|
||||
)
|
||||
.join('\n')}`
|
||||
: '';
|
||||
|
||||
const resourcesSection =
|
||||
ecsConfig.cpu || ecsConfig.memory
|
||||
? `
|
||||
resources:
|
||||
requests:${
|
||||
ecsConfig.cpu
|
||||
? `
|
||||
cpu: "${parseInt(ecsConfig.cpu)}m"`
|
||||
: ''
|
||||
}${
|
||||
ecsConfig.memory
|
||||
? `
|
||||
memory: "${ecsConfig.memory}Mi"`
|
||||
: ''
|
||||
}
|
||||
limits:${
|
||||
ecsConfig.cpu
|
||||
? `
|
||||
cpu: "${parseInt(ecsConfig.cpu)}m"`
|
||||
: ''
|
||||
}${
|
||||
ecsConfig.memory
|
||||
? `
|
||||
memory: "${ecsConfig.memory}Mi"`
|
||||
: ''
|
||||
}`
|
||||
: '';
|
||||
|
||||
return `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${deploymentName}
|
||||
namespace: ${selectedNamespace === 'all' ? 'default' : selectedNamespace}
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
imported-from: aws-ecs
|
||||
spec:
|
||||
replicas: ${parseInt(replicas)}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${deploymentName}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${deploymentName}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${sanitizeKubernetesName(container.name)}
|
||||
image: ${container.image}${portsSection}${envSection}${envFromSection}${resourcesSection}`;
|
||||
};
|
||||
|
||||
const handleCreateDeployment = async () => {
|
||||
if (!parsedConfig || !deploymentName) return;
|
||||
|
||||
setConverting(true);
|
||||
try {
|
||||
const resources: string[] = [];
|
||||
|
||||
// Create ConfigMap if needed
|
||||
if (createConfigMap) {
|
||||
const configMapYaml = createConfigMapYaml(parsedConfig);
|
||||
if (configMapYaml) {
|
||||
resources.push(configMapYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Secret if needed
|
||||
if (createSecrets) {
|
||||
const secretYaml = createSecretYaml(parsedConfig);
|
||||
if (secretYaml) {
|
||||
resources.push(secretYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Deployment
|
||||
const kubernetesConfig = convertECSToKubernetes(parsedConfig);
|
||||
resources.push(kubernetesConfig);
|
||||
|
||||
// Create Service if needed
|
||||
if (createService) {
|
||||
const serviceYaml = createServiceYaml(parsedConfig);
|
||||
if (serviceYaml) {
|
||||
resources.push(serviceYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all resources
|
||||
const allResources = resources.join('\n---\n');
|
||||
const tempFile = `/tmp/ecs-import-${Date.now()}.yaml`;
|
||||
const writeCommand = `echo '${allResources.replace(/'/g, "'\"'\"'")}' > ${tempFile}`;
|
||||
|
||||
await executeCommand(writeCommand);
|
||||
const applyResult = await executeCommand(`kubectl apply -f ${tempFile}`);
|
||||
|
||||
// Cleanup
|
||||
executeCommand(`rm -f ${tempFile}`);
|
||||
|
||||
if (applyResult.success) {
|
||||
const resourcesCreated = [];
|
||||
if (createConfigMap && createConfigMapYaml(parsedConfig))
|
||||
resourcesCreated.push('ConfigMap');
|
||||
if (createSecrets && createSecretYaml(parsedConfig))
|
||||
resourcesCreated.push('Secret');
|
||||
resourcesCreated.push('Deployment');
|
||||
if (createService && createServiceYaml(parsedConfig))
|
||||
resourcesCreated.push('Service');
|
||||
|
||||
showToast({
|
||||
title: 'Recursos Criados',
|
||||
description: `${resourcesCreated.join(', ')} criados com sucesso: "${deploymentName}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
// Navigate to deployments list
|
||||
setTimeout(() => {
|
||||
navigate('/workloads/deployments');
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar Recursos',
|
||||
description: applyResult.error || 'Falha ao aplicar configuração',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: error instanceof Error ? error.message : 'Erro inesperado',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Cloud className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Importar AWS ECS Task Definition
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cole o JSON da Task Definition do ECS para converter em Deployment
|
||||
Kubernetes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard Steps */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
1
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Tipo de Importação</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-medium">
|
||||
2
|
||||
</div>
|
||||
<span className="ml-2 font-medium">Configuração ECS</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
3
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Revisão</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Input Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
Task Definition JSON
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="json-input">Cole o JSON da Task Definition</Label>
|
||||
<Textarea
|
||||
id="json-input"
|
||||
placeholder='{"family": "my-app", "containerDefinitions": [...], ...}'
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleFileSelect} variant="outline">
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Localizar
|
||||
</Button>
|
||||
<Button onClick={handleParseJSON} disabled={!jsonInput.trim()}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Analisar JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<div className="flex items-center text-red-600 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedConfig && (
|
||||
<div className="flex items-center text-green-600 text-sm">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
JSON válido! Family: {parsedConfig.family}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configuration Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuração do Deployment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="deployment-name">Nome do Deployment</Label>
|
||||
<Input
|
||||
id="deployment-name"
|
||||
placeholder="my-app"
|
||||
value={deploymentName}
|
||||
onChange={(e) => setDeploymentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={
|
||||
selectedNamespace === 'all' ? 'default' : selectedNamespace
|
||||
}
|
||||
onValueChange={setSelectedNamespace}
|
||||
disabled={namespacesLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
namespacesLoading
|
||||
? 'Carregando namespaces...'
|
||||
: 'Selecione o namespace'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>
|
||||
{ns}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="replicas">Número de Réplicas</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={replicas}
|
||||
onChange={(e) => setReplicas(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{parsedConfig && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Recursos a Criar:</h4>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-service"
|
||||
checked={createService}
|
||||
onChange={(e) => setCreateService(e.target.checked)}
|
||||
disabled={
|
||||
!parsedConfig.containerDefinitions[0]?.portMappings
|
||||
?.length
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="create-service" className="text-sm">
|
||||
Service{' '}
|
||||
{!parsedConfig.containerDefinitions[0]?.portMappings
|
||||
?.length && '(sem portas disponíveis)'}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-configmap"
|
||||
checked={createConfigMap}
|
||||
onChange={(e) => setCreateConfigMap(e.target.checked)}
|
||||
disabled={
|
||||
!parsedConfig.containerDefinitions[0]?.environment
|
||||
?.length
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="create-configmap" className="text-sm">
|
||||
ConfigMap{' '}
|
||||
{!parsedConfig.containerDefinitions[0]?.environment
|
||||
?.length && '(sem variáveis de ambiente)'}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-secrets"
|
||||
checked={createSecrets}
|
||||
onChange={(e) => setCreateSecrets(e.target.checked)}
|
||||
disabled={
|
||||
!parsedConfig.containerDefinitions[0]?.secrets?.length
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="create-secrets" className="text-sm">
|
||||
Secrets{' '}
|
||||
{!parsedConfig.containerDefinitions[0]?.secrets?.length &&
|
||||
'(sem secrets disponíveis)'}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium mb-2">Preview da Configuração:</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<strong>Container:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.name}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Imagem:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.image}
|
||||
</p>
|
||||
<p>
|
||||
<strong>CPU:</strong>{' '}
|
||||
{parsedConfig.cpu || 'Não especificado'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Memória:</strong>{' '}
|
||||
{parsedConfig.memory || 'Não especificado'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Portas:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.portMappings
|
||||
?.map((p) => p.containerPort)
|
||||
.join(', ') || 'Nenhuma'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Environment Vars:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.environment
|
||||
?.length || 0}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Secrets:</strong>{' '}
|
||||
{parsedConfig.containerDefinitions[0]?.secrets?.length ||
|
||||
0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(createService || createConfigMap || createSecrets) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Recursos que serão criados:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline">Deployment</Badge>
|
||||
{createService && createServiceYaml(parsedConfig) && (
|
||||
<Badge variant="outline">Service</Badge>
|
||||
)}
|
||||
{createConfigMap &&
|
||||
createConfigMapYaml(parsedConfig) && (
|
||||
<Badge variant="outline">ConfigMap</Badge>
|
||||
)}
|
||||
{createSecrets && createSecretYaml(parsedConfig) && (
|
||||
<Badge variant="outline">Secret</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCreateDeployment}
|
||||
disabled={!parsedConfig || !deploymentName || converting}
|
||||
>
|
||||
{converting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando Deployment...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Criar Deployment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
src/components/pages/deployment-import.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Upload,
|
||||
Container,
|
||||
GitBranch,
|
||||
Database,
|
||||
Globe,
|
||||
Cloud,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DeploymentImportPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedImportType, setSelectedImportType] = useState<string>('');
|
||||
|
||||
const importTypes = [
|
||||
{
|
||||
value: 'yaml',
|
||||
label: 'YAML/JSON File',
|
||||
description: 'Importar de arquivo YAML ou JSON local',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
value: 'ecs',
|
||||
label: 'AWS ECS Task Definition',
|
||||
description: 'Converter Task Definition do ECS para Kubernetes',
|
||||
icon: Cloud,
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: 'URL Remote',
|
||||
description: 'Importar de URL remota (GitHub, GitLab, etc.)',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
value: 'template',
|
||||
label: 'Template Predefinido',
|
||||
description: 'Usar template de deployment comum',
|
||||
icon: Container,
|
||||
},
|
||||
{
|
||||
value: 'docker',
|
||||
label: 'Docker Image',
|
||||
description: 'Criar deployment a partir de imagem Docker',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
value: 'git',
|
||||
label: 'Repositório Git',
|
||||
description: 'Clonar e deployar de repositório Git',
|
||||
icon: GitBranch,
|
||||
},
|
||||
];
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedImportType) {
|
||||
// Navegar para a próxima etapa baseada no tipo selecionado
|
||||
navigate(`/workloads/deployments/import/${selectedImportType}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Upload className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Assistente de Importação
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Escolha o tipo de importação para criar um novo deployment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard Steps */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-medium">
|
||||
1
|
||||
</div>
|
||||
<span className="ml-2 font-medium">Tipo de Importação</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
2
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Configuração</span>
|
||||
</div>
|
||||
<div className="h-px bg-gray-300 flex-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-gray-200 text-gray-600 rounded-full flex items-center justify-center font-medium">
|
||||
3
|
||||
</div>
|
||||
<span className="ml-2 text-gray-600">Revisão</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Type Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selecione o Tipo de Importação</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Select onValueChange={setSelectedImportType}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Escolha como você deseja importar o deployment..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{importTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex items-center">
|
||||
<type.icon className="h-4 w-4 mr-2" />
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Detailed Options */}
|
||||
{selectedImportType && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{importTypes
|
||||
.filter((type) => type.value === selectedImportType)
|
||||
.map((type) => {
|
||||
const IconComponent = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className="border-2 border-blue-500 bg-blue-50"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center text-lg">
|
||||
<IconComponent className="h-5 w-5 mr-2 text-blue-600" />
|
||||
{type.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{type.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Options Preview */}
|
||||
{!selectedImportType && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{importTypes.map((type) => {
|
||||
const IconComponent = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => setSelectedImportType(type.value)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center text-lg">
|
||||
<IconComponent className="h-5 w-5 mr-2 text-gray-600" />
|
||||
{type.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{type.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleContinue} disabled={!selectedImportType}>
|
||||
Continuar
|
||||
<ArrowLeft className="h-4 w-4 ml-2 rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1580
src/components/pages/deployments.tsx
Normal file
528
src/components/pages/hpa-create.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useKubectl, useNamespacesInfo } from '@/hooks/useKubectl';
|
||||
import { useGlobalNamespace } from '@/hooks/useGlobalNamespace';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Target,
|
||||
Zap,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function HPACreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { executeCommand } = useKubectl();
|
||||
const { namespaces } = useNamespacesInfo();
|
||||
const { selectedNamespace: globalNamespace } = useGlobalNamespace();
|
||||
|
||||
const [hpaName, setHpaName] = useState('');
|
||||
const [selectedNamespace, setSelectedNamespace] = useState(() => {
|
||||
return globalNamespace === 'all' ? 'default' : globalNamespace;
|
||||
});
|
||||
const [targetKind, setTargetKind] = useState<'Deployment' | 'StatefulSet'>('Deployment');
|
||||
const [targetName, setTargetName] = useState('');
|
||||
const [targets, setTargets] = useState<Array<{ name: string; namespace: string }>>([]);
|
||||
const [minReplicas, setMinReplicas] = useState(1);
|
||||
const [maxReplicas, setMaxReplicas] = useState(10);
|
||||
const [cpuTarget, setCpuTarget] = useState(80);
|
||||
const [enableMemoryMetric, setEnableMemoryMetric] = useState(false);
|
||||
const [memoryTarget, setMemoryTarget] = useState(80);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Buscar targets disponíveis quando namespace ou kind mudar
|
||||
useEffect(() => {
|
||||
const fetchTargets = async () => {
|
||||
try {
|
||||
const kind = targetKind.toLowerCase();
|
||||
const command = `kubectl get ${kind} -n ${selectedNamespace} -o json`;
|
||||
const result = await executeCommand(command);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const data = JSON.parse(result.data);
|
||||
const targetsList = data.items.map((item: any) => ({
|
||||
name: item.metadata.name,
|
||||
namespace: item.metadata.namespace,
|
||||
}));
|
||||
setTargets(targetsList);
|
||||
|
||||
// Reset target name se não existir na nova lista
|
||||
if (targetName && !targetsList.some((t: any) => t.name === targetName)) {
|
||||
setTargetName('');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar targets:', err);
|
||||
setTargets([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTargets();
|
||||
}, [selectedNamespace, targetKind, executeCommand]);
|
||||
|
||||
const validateForm = () => {
|
||||
if (!hpaName.trim()) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Nome do HPA é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validação RFC 1123: lowercase, alfanumérico, hífens e pontos
|
||||
const rfc1123Regex = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
|
||||
if (!rfc1123Regex.test(hpaName)) {
|
||||
showToast({
|
||||
title: 'Nome Inválido',
|
||||
description: 'O nome deve conter apenas letras minúsculas, números, hífens (-) e pontos (.). Deve começar e terminar com alfanumérico.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Namespace é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!targetName) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Target (Deployment/StatefulSet) é obrigatório',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (minReplicas < 1) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Mínimo de réplicas deve ser pelo menos 1',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxReplicas < minReplicas) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Máximo de réplicas deve ser maior ou igual ao mínimo',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cpuTarget < 1 || cpuTarget > 100) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Target de CPU deve estar entre 1% e 100%',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableMemoryMetric && (memoryTarget < 1 || memoryTarget > 100)) {
|
||||
showToast({
|
||||
title: 'Erro de Validação',
|
||||
description: 'Target de memória deve estar entre 1% e 100%',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateHPA = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const memoryMetricYaml = enableMemoryMetric
|
||||
? ` - type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: ${memoryTarget}`
|
||||
: '';
|
||||
|
||||
const hpaYaml = `apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: ${hpaName}
|
||||
namespace: ${selectedNamespace}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: ${targetKind}
|
||||
name: ${targetName}
|
||||
minReplicas: ${minReplicas}
|
||||
maxReplicas: ${maxReplicas}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: ${cpuTarget}${memoryMetricYaml ? '\n' + memoryMetricYaml : ''}`;
|
||||
|
||||
// Create temporary file with the HPA spec
|
||||
const tempFileName = `/tmp/hpa-${hpaName}-${Date.now()}.yaml`;
|
||||
const writeCommand = `cat > "${tempFileName}" << 'EOF'
|
||||
${hpaYaml}
|
||||
EOF`;
|
||||
|
||||
const writeResult = await executeCommand(writeCommand);
|
||||
if (!writeResult.success) {
|
||||
throw new Error('Falha ao criar arquivo temporário');
|
||||
}
|
||||
|
||||
// Apply the HPA
|
||||
const applyCommand = `kubectl apply -f "${tempFileName}"`;
|
||||
const result = await executeCommand(applyCommand);
|
||||
|
||||
// Clean up temp file
|
||||
await executeCommand(`rm -f "${tempFileName}"`);
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
title: 'HPA Criado com Sucesso',
|
||||
description: `HPA "${hpaName}" foi criado no namespace "${selectedNamespace}"`,
|
||||
variant: 'success',
|
||||
});
|
||||
navigate('/workloads/hpa');
|
||||
} else {
|
||||
showToast({
|
||||
title: 'Erro ao Criar HPA',
|
||||
description: result.error || 'Falha ao criar o HPA',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Erro inesperado';
|
||||
showToast({
|
||||
title: 'Erro',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center">
|
||||
<Activity className="h-8 w-8 mr-3 text-blue-600" />
|
||||
Criar Horizontal Pod Autoscaler
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure autoscaling automático baseado em métricas de CPU e memória
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Informações Básicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="hpaName">Nome do HPA</Label>
|
||||
<Input
|
||||
id="hpaName"
|
||||
placeholder="ex: my-app-hpa"
|
||||
value={hpaName}
|
||||
onChange={(e) => setHpaName(e.target.value.toLowerCase())}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Apenas minúsculas, números, hífens e pontos. Ex: my-app-hpa
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namespace">Namespace</Label>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
onValueChange={setSelectedNamespace}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns.name} value={ns.name}>
|
||||
{ns.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Target Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Target className="h-5 w-5 mr-2" />
|
||||
Target de Escalação
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="targetKind">Tipo de Recurso</Label>
|
||||
<Select
|
||||
value={targetKind}
|
||||
onValueChange={(value: 'Deployment' | 'StatefulSet') => setTargetKind(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Deployment">Deployment</SelectItem>
|
||||
<SelectItem value="StatefulSet">StatefulSet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Tipo de workload que será escalado
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="targetName">Nome do Target</Label>
|
||||
<Select
|
||||
value={targetName}
|
||||
onValueChange={setTargetName}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={targets.length > 0 ? "Selecione um target" : "Nenhum target disponível"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targets.map((target) => (
|
||||
<SelectItem key={target.name} value={target.name}>
|
||||
{target.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{targets.length > 0
|
||||
? `${targets.length} ${targetKind}(s) disponível(is) em ${selectedNamespace}`
|
||||
: `Nenhum ${targetKind} encontrado em ${selectedNamespace}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{targetName && (
|
||||
<div className="p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>🎯 Target Selecionado:</strong> {targetKind} "{targetName}"
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Este HPA irá escalar automaticamente as réplicas deste {targetKind} baseado nas métricas configuradas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Replicas Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
Configuração de Réplicas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="minReplicas">Mínimo de Réplicas</Label>
|
||||
<Input
|
||||
id="minReplicas"
|
||||
type="number"
|
||||
min="1"
|
||||
value={minReplicas}
|
||||
onChange={(e) => setMinReplicas(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Número mínimo de pods sempre em execução
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxReplicas">Máximo de Réplicas</Label>
|
||||
<Input
|
||||
id="maxReplicas"
|
||||
type="number"
|
||||
min={minReplicas}
|
||||
value={maxReplicas}
|
||||
onChange={(e) => setMaxReplicas(parseInt(e.target.value) || 10)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Número máximo de pods permitido durante picos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 rounded-md border">
|
||||
<h4 className="font-medium text-sm mb-2">Preview da Configuração</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p><strong>Range de Réplicas:</strong> {minReplicas} - {maxReplicas} pods</p>
|
||||
<p><strong>Fator de Escalação:</strong> até {Math.floor((maxReplicas / minReplicas) * 100)}% de aumento</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metrics Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
Métricas de Escalação
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* CPU Metric */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cpuTarget">Target de CPU (Utilização)</Label>
|
||||
<span className="text-2xl font-bold text-blue-600">{cpuTarget}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id="cpuTarget"
|
||||
min="10"
|
||||
max="100"
|
||||
step="5"
|
||||
value={cpuTarget}
|
||||
onChange={(e) => setCpuTarget(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quando a utilização média de CPU ultrapassar {cpuTarget}%, o HPA irá aumentar as réplicas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Memory Metric (Optional) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableMemory"
|
||||
checked={enableMemoryMetric}
|
||||
onChange={(e) => setEnableMemoryMetric(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="enableMemory" className="cursor-pointer">
|
||||
Habilitar métrica de memória
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{enableMemoryMetric && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="memoryTarget">Target de Memória (Utilização)</Label>
|
||||
<span className="text-2xl font-bold text-green-600">{memoryTarget}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id="memoryTarget"
|
||||
min="10"
|
||||
max="100"
|
||||
step="5"
|
||||
value={memoryTarget}
|
||||
onChange={(e) => setMemoryTarget(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quando a utilização média de memória ultrapassar {memoryTarget}%, o HPA irá aumentar as réplicas
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<div className="flex items-start">
|
||||
<Info className="h-4 w-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">Como funciona o HPA?</p>
|
||||
<ul className="text-xs text-blue-600 space-y-1">
|
||||
<li>• O HPA verifica as métricas a cada 15 segundos</li>
|
||||
<li>• Se a utilização ultrapassar o target, aumenta as réplicas</li>
|
||||
<li>• Se a utilização ficar abaixo do target, diminui as réplicas</li>
|
||||
<li>• Sempre respeita os limites mínimo e máximo configurados</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateHPA}
|
||||
disabled={creating || !hpaName.trim() || !targetName}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Criar HPA
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HPACreatePage;
|
||||