릴리즈 v0.2.7: 빌드 오류 수정, supervisor 권한 보완 및 Gitea 설정 기능 고도화

This commit is contained in:
choibk 2026-01-24 20:24:20 +09:00
parent 98b52c390e
commit bc996a3980
7 changed files with 205 additions and 72 deletions

9
.gitignore vendored
View File

@ -22,14 +22,6 @@ dist-ssr
build build
out out
# Environment Variables (CRITICAL)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.production
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@ -50,7 +42,6 @@ Desktop.ini
server/uploads/* server/uploads/*
!server/uploads/.gitkeep !server/uploads/.gitkeep
server/server.zip server/server.zip
server/public_key.pem
server/private_key.pem server/private_key.pem
# Project Specific - Camera/Stream # Project Specific - Camera/Stream

View File

@ -22,19 +22,22 @@ SmartIMS 배포는 **Git Tag 기반 배포** 방식을 권장합니다. 이는
### 2.2. 설치 단계 ### 2.2. 설치 단계
1. **소스 클론**: 1. **소스 클론**:
`/volume1/web` 위치에서 아래 명령어를 실행하면 `smartims` 폴더가 생성되며 그 안에 소스가 들어갑니다.
```bash ```bash
git clone [저장소_URL] smartims cd /volume1/web
# 저장소 URL 뒤에 'smartims'를 붙여 폴더명을 지정합니다.
git clone https://gitea.qideun.com/SOKUREE/smart_ims.git smartims
cd smartims cd smartims
``` ```
2. **안정 버전(Tag) 전환**: 2. **안정 버전(Tag) 전환**:
```bash ```bash
git fetch --all --tags git fetch --all --tags
# 현재 배포 가능한 최신 태그로 전환 (예: v0.2.5) # v0.2.6 버전으로 전환
git checkout v0.2.5 git checkout v0.2.6
``` ```
3. **환경 설정**: 3. **환경 설정**:
* `server/.env` 파일을 환경에 맞게 생성(DB 정보 등 입력). * `server/.env` 파일을 환경에 맞게 생성(DB 정보 등 입력).
* `server/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용). * `server/config/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용).
4. **패키지 설치 및 빌드**: 4. **패키지 설치 및 빌드**:
```bash ```bash
# 전체 의존성 설치 및 프론트엔드 빌드 # 전체 의존성 설치 및 프론트엔드 빌드
@ -71,17 +74,19 @@ npm run build
pm2 reload smartims-api pm2 reload smartims-api
``` ```
### 3.2. 시스템 관리 메뉴를 통한 업데이트 (검토 중) ### 3.2. 시스템 관리 메뉴를 통한 업데이트 (지원됨)
시스템의 "버전 정보" 화면에서 신규 버전을 감지하고 업데이트하는 기능을 검토 중입니다. 시스템의 **"시스템 관리 > 버전 정보"** 화면에서 신규 버전을 감지하고 원 클릭으로 업데이트할 수 있습니다.
* 참고: 이 기능을 사용하려면 **[기본 설정]** 메뉴에서 Gitea 원격 저장소 URL과 (필요 시) 계정 정보를 먼저 설정해야 합니다.
* **동작 원리**: * **동작 원리**:
1. 서버가 원격 저장소의 최신 Tag 리스트를 정기적으로 또는 요청 시 확인합니다. 1. 서버가 설정된 원격 저장소의 최신 Tag 리스트를 확인합니다.
2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교하여 업데이트 버튼을 활성화합니다. 2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교합니다.
3. 업데이트 실행 시 서버 내부적으로 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동화된 스크립트로 수행합니다. 3. 업데이트 실행 시 서버가 백그라운드에서 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동로 수행합니다.
* **기대 효과**: * **기대 효과**:
* 터미널(SSH) 접속 없이 관리자 화면에서 즉시 최신 기능 반영 가능. * 터미널(SSH) 접속 없이 관리자 화면에서 즉시 최신 기능 반영 가능.
* 버전 불일치 문제 예방 및 운영 편의성 증대. * 운영 서버 환경에서도 편리한 버전 관리.
--- ---

23
server/.env Normal file
View File

@ -0,0 +1,23 @@
# ==============================================
# [Common Settings]
# ==============================================
DB_HOST=sokuree.com
DB_USER=choibk
DB_PASSWORD=^Ocean1472bk
PORT=3005
# ==============================================
# [Development Environment] - Local Windows
# ==============================================
# 로컬 개발용 DB (분리됨: sokuree_platform_dev)
DB_NAME=sokuree_platform_dev
DB_PORT=3307
# Windows 환경 호환성 (tcp는 권한 오류 발생 가능)
CCTV_TRANSPORT_OVERRIDE=auto
# ==============================================
# [Production Environment] - Synology NAS
# ==============================================
# DB_NAME=sokuree_platform_prod
# DB_PORT=3307

View File

@ -7,6 +7,7 @@ const fs = require('fs');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateLicense, verifyLicense } = require('../utils/licenseManager'); const { generateLicense, verifyLicense } = require('../utils/licenseManager');
const { checkRemoteKey } = require('../utils/remoteLicense'); const { checkRemoteKey } = require('../utils/remoteLicense');
const cryptoUtil = require('../utils/cryptoUtil');
// Load Public Key for Verification // Load Public Key for Verification
const publicKeyPath = path.join(__dirname, '../config/public_key.pem'); const publicKeyPath = path.join(__dirname, '../config/public_key.pem');
@ -32,7 +33,10 @@ const ALLOWED_SETTING_KEYS = [
'asset_categories', 'asset_categories',
'asset_locations', 'asset_locations',
'asset_statuses', 'asset_statuses',
'asset_maintenance_types' 'asset_maintenance_types',
'gitea_url',
'gitea_user',
'gitea_password'
]; ];
// --- .env File Utilities --- // --- .env File Utilities ---
@ -70,7 +74,7 @@ const mysql = require('mysql2/promise');
// 0. Server Configuration (Subscriber ID & Session Timeout) // 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')"); const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key', 'gitea_url', 'gitea_user', 'gitea_password')");
const settings = {}; const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value); rows.forEach(r => settings[r.setting_key] = r.setting_value);
@ -81,6 +85,9 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
subscriber_id: settings.subscriber_id || '', subscriber_id: settings.subscriber_id || '',
session_timeout: parseInt(settings.session_timeout) || 60, session_timeout: parseInt(settings.session_timeout) || 60,
encryption_key: settings.encryption_key || '', encryption_key: settings.encryption_key || '',
gitea_url: settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git',
gitea_user: settings.gitea_user || '',
gitea_password: settings.gitea_password ? cryptoUtil.decryptMasterKey(settings.gitea_password) : '',
db_config: { db_config: {
host: env.DB_HOST || '', host: env.DB_HOST || '',
user: env.DB_USER || '', user: env.DB_USER || '',
@ -109,6 +116,16 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key); const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
} }
if (req.body.gitea_url !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_url', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_url]);
}
if (req.body.gitea_user !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_user', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_user]);
}
if (req.body.gitea_password !== undefined) {
const encryptedPass = cryptoUtil.encryptMasterKey(req.body.gitea_password);
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_password', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedPass]);
}
// Handle .env DB settings // Handle .env DB settings
if (db_config) { if (db_config) {
@ -129,7 +146,6 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
}); });
// --- Crypto & Key Rotation --- // --- Crypto & Key Rotation ---
const cryptoUtil = require('../utils/cryptoUtil');
// 0.2 Test DB Connection // 0.2 Test DB Connection
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => { router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
@ -234,8 +250,8 @@ router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res
try { try {
let stringValue = typeof value === 'string' ? value : JSON.stringify(value); let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
// Special handling for encryption_key to protect it in DB // Special handling for sensitive keys to protect it in DB
if (key === 'encryption_key') { if (key === 'encryption_key' || key === 'gitea_password') {
stringValue = cryptoUtil.encryptMasterKey(stringValue); stringValue = cryptoUtil.encryptMasterKey(stringValue);
} }
@ -427,6 +443,25 @@ router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), asyn
// --- System Update Logic --- // --- System Update Logic ---
const getGiteaAuth = async () => {
try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('gitea_url', 'gitea_user', 'gitea_password')");
const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value);
const url = settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git';
if (settings.gitea_user && settings.gitea_password) {
const pass = cryptoUtil.decryptMasterKey(settings.gitea_password);
return { url, user: settings.gitea_user, pass: pass };
}
return { url, user: null, pass: null };
} catch (e) {
console.error('Failed to get Gitea auth:', e);
}
return { url: 'https://gitea.qideun.com/SOKUREE/smart_ims.git', user: null, pass: null };
};
// 5. Get Version Info (Current & Remote) // 5. Get Version Info (Current & Remote)
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
@ -434,15 +469,27 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version; const currentVersion = packageJson.version;
// Run git fetch to update tags from remote // Prepare git fetch command with auth if available
exec('git fetch --tags', (err, stdout, stderr) => { const auth = await getGiteaAuth();
let fetchCmd = 'git fetch --tags';
if (auth.user && auth.pass) {
// Inject auth into URL
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
fetchCmd = `git fetch ${authenticatedUrl} --tags`;
} else {
fetchCmd = `git fetch ${auth.url} --tags`;
}
exec(fetchCmd, (err, stdout, stderr) => {
if (err) { if (err) {
console.error('Git fetch failed:', err); console.error('Git fetch failed:', err);
console.error('Stderr:', stderr); // Mask password in error message
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
return res.json({ return res.json({
current: currentVersion, current: currentVersion,
latest: null, latest: null,
error: `원격 저장소 동기화 실패: ${stderr || err.message}` error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}`
}); });
} }
@ -485,7 +532,18 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
// This operation is asynchronous. We start it and return a message. // This operation is asynchronous. We start it and return a message.
// In a real production, we might want to log this to a terminal-like view. // In a real production, we might want to log this to a terminal-like view.
const auth = await getGiteaAuth();
let authPrefix = '';
if (auth.user && auth.pass) {
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
authPrefix = `git remote set-url origin ${authenticatedUrl} && `;
} else {
authPrefix = `git remote set-url origin ${auth.url} && `;
}
const updateScript = ` const updateScript = `
${authPrefix}
git fetch --tags &&
git checkout ${targetTag} && git checkout ${targetTag} &&
npm install && npm install &&
npm run build && npm run build &&

View File

@ -1,23 +1,27 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ModuleGuard } from '../shared/auth/ModuleGuard'; import { AuthProvider } from '../shared/auth/AuthContext';
import { SystemProvider } from '../shared/context/SystemContext';
import { AuthGuard } from '../shared/auth/AuthGuard';
import { MainLayout } from '../widgets/layout/MainLayout'; import { MainLayout } from '../widgets/layout/MainLayout';
import { LoginPage } from '../pages/auth/LoginPage'; import { LoginPage } from '../pages/auth/LoginPage';
import { DashboardPage } from '../modules/asset/pages/DashboardPage';
import { AssetListPage } from '../modules/asset/pages/AssetListPage'; // Modules
import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage'; import { assetModule } from '../modules/asset/module';
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage'; import { cctvModule } from '../modules/cctv/module';
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage'; import { productionModule } from '../modules/production/module';
import ModuleLoader from '../platform/ModuleLoader';
// Platform / System Pages
import { UserManagementPage } from '../platform/pages/UserManagementPage'; import { UserManagementPage } from '../platform/pages/UserManagementPage';
import { BasicSettingsPage } from '../platform/pages/BasicSettingsPage'; import { BasicSettingsPage } from '../platform/pages/BasicSettingsPage';
import { ProductionPage } from '../production/pages/ProductionPage'; import { VersionPage } from '../platform/pages/VersionPage';
import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage';
import { AuthProvider } from '../shared/auth/AuthContext';
import { AuthGuard } from '../shared/auth/AuthGuard';
import { SystemProvider } from '../shared/context/SystemContext';
import { LicensePage } from '../system/pages/LicensePage'; import { LicensePage } from '../system/pages/LicensePage';
function App() { import '../platform/styles/global.css';
const modules = [assetModule, cctvModule, productionModule];
export function App() {
return ( return (
<AuthProvider> <AuthProvider>
<SystemProvider> <SystemProvider>
@ -29,39 +33,20 @@ function App() {
{/* Protected Routes */} {/* Protected Routes */}
<Route element={ <Route element={
<AuthGuard> <AuthGuard>
<MainLayout /> <MainLayout modulesList={modules} />
</AuthGuard> </AuthGuard>
}> }>
{/* Asset Management Routes */} {/* Dynamic Module Routes */}
<Route element={<ModuleGuard moduleCode="asset"><Outlet /></ModuleGuard>}> <Route path="/*" element={<ModuleLoader modules={modules} />} />
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
<Route path="/asset/dashboard" element={<DashboardPage />} />
<Route path="/asset/register" element={<AssetRegisterPage />} />
<Route path="/asset/list" element={<AssetListPage />} />
<Route path="/asset/facilities" element={<AssetListPage />} />
<Route path="/asset/tools" element={<AssetListPage />} />
<Route path="/asset/general" element={<AssetListPage />} />
<Route path="/asset/settings" element={<AssetSettingsPage />} />
<Route path="/asset/detail/:assetId" element={<AssetDetailPage />} />
<Route path="/asset/consumables" element={<AssetListPage />} />
<Route path="/asset/instruments" element={<AssetListPage />} />
<Route path="/asset/vehicles" element={<AssetListPage />} />
</Route>
{/* Production Management Routes */} {/* Navigation Fallback within Layout */}
<Route element={<ModuleGuard moduleCode="production"><Outlet /></ModuleGuard>}> <Route index element={<Navigate to="/asset/dashboard" replace />} />
<Route path="/production/dashboard" element={<ProductionPage />} />
</Route>
{/* Monitoring Routes */} {/* Platform Admin Routes */}
<Route element={<ModuleGuard moduleCode="monitoring"><Outlet /></ModuleGuard>}>
<Route path="/monitoring" element={<MonitoringPage />} />
</Route>
{/* Admin Routes */}
<Route path="/admin/users" element={<UserManagementPage />} /> <Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/settings" element={<BasicSettingsPage />} /> <Route path="/admin/settings" element={<BasicSettingsPage />} />
<Route path="/admin/license" element={<LicensePage />} /> <Route path="/admin/license" element={<LicensePage />} />
<Route path="/admin/version" element={<VersionPage />} />
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} /> <Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
</Route> </Route>

View File

@ -33,9 +33,12 @@ export const ModuleLoader = ({ modules }: ModuleLoaderProps) => {
if (!isActive) return null; if (!isActive) return null;
// 2. 沅뚰븳 泥댄겕 (Role 湲곕컲) // 2. 권한 체크 (Role 기반)
if (module.requiredRoles && user && !module.requiredRoles.includes(user.role)) { if (module.requiredRoles && user) {
return null; // supervisor는 모든 권한을 가짐
if (user.role !== 'supervisor' && !module.requiredRoles.includes(user.role)) {
return null;
}
} }
return ( return (

View File

@ -10,6 +10,9 @@ interface SystemSettings {
session_timeout: number; session_timeout: number;
encryption_key: string; encryption_key: string;
subscriber_id: string; subscriber_id: string;
gitea_url: string;
gitea_user: string;
gitea_password: string;
db_config: { db_config: {
host: string; host: string;
user: string; user: string;
@ -25,6 +28,9 @@ export function BasicSettingsPage() {
session_timeout: 60, session_timeout: 60,
encryption_key: '', encryption_key: '',
subscriber_id: '', subscriber_id: '',
gitea_url: '',
gitea_user: '',
gitea_password: '',
db_config: { db_config: {
host: '', host: '',
user: '', user: '',
@ -39,6 +45,7 @@ export function BasicSettingsPage() {
const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({ const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({
security: null, security: null,
encryption: null, encryption: null,
repository: null,
database: null database: null
}); });
@ -176,7 +183,7 @@ export function BasicSettingsPage() {
} }
}; };
const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => { const handleSaveSection = async (section: 'security' | 'encryption' | 'database' | 'repository') => {
if (section === 'database' && !isDbVerified) { if (section === 'database' && !isDbVerified) {
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.'); alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
return; return;
@ -187,6 +194,11 @@ export function BasicSettingsPage() {
if (section === 'security') payload = { session_timeout: settings.session_timeout }; if (section === 'security') payload = { session_timeout: settings.session_timeout };
else if (section === 'encryption') payload = { encryption_key: settings.encryption_key }; else if (section === 'encryption') payload = { encryption_key: settings.encryption_key };
else if (section === 'repository') payload = {
gitea_url: settings.gitea_url,
gitea_user: settings.gitea_user,
gitea_password: settings.gitea_password
};
else if (section === 'database') { else if (section === 'database') {
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return; if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
payload = { db_config: settings.db_config }; payload = { db_config: settings.db_config };
@ -351,6 +363,62 @@ export function BasicSettingsPage() {
)} )}
</Card> </Card>
{/* Section 2.5: Gitea Repository Update Settings */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<RefreshCcw size={20} className="text-emerald-600" />
(Gitea)
</h2>
</div>
<div className="p-6">
<p className="text-xs text-slate-500 mb-4"> (Gitea) .</p>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> URL (Git )</label>
<Input
value={settings.gitea_url}
onChange={e => setSettings({ ...settings, gitea_url: e.target.value })}
placeholder="https://gitea.example.com/org/repo.git"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">Gitea ID</label>
<Input
value={settings.gitea_user}
onChange={e => setSettings({ ...settings, gitea_user: e.target.value })}
placeholder="gitea_update_user"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">Gitea ( Token)</label>
<Input
type="password"
value={settings.gitea_password}
onChange={e => setSettings({ ...settings, gitea_password: e.target.value })}
placeholder="••••••••"
/>
</div>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
<div className="flex-1">
{saveResults.repository && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.repository.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.repository.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.repository.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={fetchSettings}></Button>
<Button size="sm" onClick={() => handleSaveSection('repository')} disabled={saving} icon={<Save size={14} />}> </Button>
</div>
</div>
</Card>
{/* Section 3: Database Infrastructure (Supervisor Protected) */} {/* Section 3: Database Infrastructure (Supervisor Protected) */}
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100"> <Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center"> <div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">