commit inicial do projeto
Some checks failed
Test / test (macos-latest) (push) Waiting to run
Test / test (windows-latest) (push) Waiting to run
Test / test (ubuntu-latest) (push) Failing after 13m2s

This commit is contained in:
Junior
2026-04-21 18:18:56 -03:00
commit 15e6692647
187 changed files with 102037 additions and 0 deletions

12
.editorconfig Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off"
}
}

View 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;

View File

@@ -0,0 +1,3 @@
/* eslint import/no-unresolved: off, import/no-self-import: off */
module.exports = require('./webpack.config.renderer.dev').default;

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

BIN
.erb/img/erb-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

1
.erb/mocks/fileMock.js Normal file
View File

@@ -0,0 +1 @@
export default 'test-file-stub';

8
.erb/scripts/.eslintrc Normal file
View File

@@ -0,0 +1,8 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off",
"import/no-extraneous-dependencies": "off"
}
}

View 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;
}

View 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');
}
}

View 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);
}
}

View 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
View 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);
}
}
});

View 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;

View 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',
});
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
-->

View 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
View File

@@ -0,0 +1,6 @@
requiredHeaders:
- Prerequisites
- Expected Behavior
- Current Behavior
- Possible Solution
- Your Environment

17
.github/stale.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"]
}

30
.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View 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;
}

View 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

Binary file not shown.

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

48
assets/icon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
assets/icons/128x128.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/icons/16x16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

BIN
assets/icons/24x24.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/icons/256x256.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/icons/32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/icons/48x48.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
assets/icons/512x512.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
assets/icons/64x64.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
assets/icons/96x96.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

18
assets/logo.svg Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

293
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

14
release/app/package-lock.json generated Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

View 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();
});
});

View 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>
);
}

View 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>
);
}

View 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;

View 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
</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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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 )</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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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;

Some files were not shown because too many files have changed in this diff Show More