From 2aa3fa1a0a455cadd438f2335d8e9d292aea6639 Mon Sep 17 00:00:00 2001 From: dongjin kim Date: Wed, 12 Nov 2025 17:16:04 +0900 Subject: [PATCH] AI Moel file management, login/ logout. --- public/app.js | 164 +++++++++++++++++++++++++++++++++++------- public/dashboard.html | 40 ++++++----- public/index.html | 49 +++++++++++-- public/style.css | 2 +- server.js | 155 ++++++++++++++++++++++++++++----------- 5 files changed, 321 insertions(+), 89 deletions(-) diff --git a/public/app.js b/public/app.js index a01a49f..807de34 100644 --- a/public/app.js +++ b/public/app.js @@ -33,16 +33,94 @@ document.addEventListener('DOMContentLoaded', () => { // 콘텐츠 표시 contentModels.classList.add('active'); contentVideo.classList.remove('active'); + + // [추가] AI 모델 탭을 클릭할 때 목록 새로고침 + loadModelList(); + }); + } + + // ========== AI 모델 목록 로드 함수 ========== + function loadModelList() { + // 테이블 본문(tbody)을 선택 + const tableBody = document.querySelector('.model-table tbody'); + if (!tableBody) return; // 테이블이 없으면 중단 + + console.log('Loading model list...'); + + fetch('/list-models') // server.js에 추가한 엔드포인트 + .then(response => response.json()) + .then(models => { + // models = { "OBJDET": { "file": "...", "version": "..." }, ... } + + // 테이블의 모든 'data-role' 행을 순회 + tableBody.querySelectorAll('tr[data-role]').forEach(row => { + const role = row.dataset.role; // e.g., "OBJDET" + const modelData = models[role]; // 해당 역할의 모델 데이터 (없으면 undefined) + + const fileCell = row.querySelector('.model-filename'); + const versionCell = row.querySelector('.model-version'); + + if (modelData) { + // 일치하는 파일이 있으면, 파일명과 버전 업데이트 + if (fileCell) fileCell.textContent = modelData.file; + if (versionCell) versionCell.textContent = modelData.version; + } else { + // 일치하는 파일이 없으면, 기본값 '-'으로 설정 + // (HTML의 v1.0 플레이스홀더를 덮어씀) + if (fileCell) fileCell.textContent = '-'; + if (versionCell) versionCell.textContent = '-'; + } + }); + }) + .catch(error => { + console.error('모델 목록 로드 실패:', error); + alert('모델 목록을 불러오는 데 실패했습니다.'); + }); + } + // ========== AI 모델 목록 로드 함수 끝 ========== + + + // ========== [추가됨] 모델 삭제 요청 함수 ========== + function deleteModelFile(filename) { + console.log(`Requesting deletion of: ${filename}`); + + fetch('/delete-model', { // server.js에 추가할 엔드포인트 + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ filename: filename }) // { "filename": "CUUVA_..." } + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + alert(`파일이 성공적으로 삭제되었습니다: ${filename}`); + loadModelList(); // 삭제 성공 시 목록 새로고침 + } else { + alert(`삭제 실패: ${data.message}`); + } + }) + .catch(error => { + console.error('Delete error:', error); + alert('삭제 중 오류가 발생했습니다.'); }); } + // ========== 모델 삭제 요청 함수 끝 ========== - // ========== [수정됨] AI 모델 업로드 로직 시작 ========== + + // ========== AI 모델 업로드/관리 로직 시작 ========== if (contentModels) { // 전역 업로드 관련 요소 가져오기 const globalFileInput = document.getElementById('global-file-input'); const fileNameDisplay = document.getElementById('file-name-display'); const globalUploadButton = document.getElementById('global-upload-button'); + // 새로고침 버튼 요소 + const refreshButton = contentModels.querySelector('.refresh-button'); + + // [추가] 모델 테이블 본문 (이벤트 위임용) + const tableBody = contentModels.querySelector('.model-table tbody'); + // '찾아보기'로 파일 선택 시 if (globalFileInput && fileNameDisplay) { globalFileInput.addEventListener('change', () => { @@ -86,7 +164,6 @@ document.addEventListener('DOMContentLoaded', () => { // FormData 객체 생성 const formData = new FormData(); formData.append('modelFile', file); // 'modelFile'은 server.js와 일치해야 함 - // [제거됨] modelId, modelRole 전송 로직 // 업로드 중 버튼 비활성화 globalUploadButton.disabled = true; @@ -103,6 +180,10 @@ document.addEventListener('DOMContentLoaded', () => { alert(`업로드 성공!\n파일: ${file.name}`); globalFileInput.value = ''; // 파일 선택 초기화 fileNameDisplay.textContent = '선택된 파일 없음'; + + // 업로드 성공 시 모델 목록 새로고침 + loadModelList(); + } else { alert(`업로드 실패: ${data.message}`); } @@ -118,12 +199,52 @@ document.addEventListener('DOMContentLoaded', () => { }); }); } + + // '새로고침' 버튼 클릭 시 + if (refreshButton) { + refreshButton.addEventListener('click', () => { + loadModelList(); // 목록 새로고침 함수 호출 + }); + } + + // ========== [추가됨] 삭제 버튼 이벤트 리스너 (이벤트 위임) ========== + if (tableBody) { + tableBody.addEventListener('click', (event) => { + // 클릭된 요소가 'btn-delete' 클래스를 가지고 있는지 확인 + if (event.target.classList.contains('btn-delete')) { + + // 클릭된 버튼에서 가장 가까운 (행)을 찾습니다. + const row = event.target.closest('tr'); + if (!row) return; + + // 행에서 파일명 셀(.model-filename)을 찾아 파일명을 가져옵니다. + const fileCell = row.querySelector('.model-filename'); + const filename = fileCell ? fileCell.textContent : null; + + // 파일명이 없거나 '-' 이면 (파일이 할당되지 않음) 중단 + if (!filename || filename === '-') { + alert('삭제할 파일이 없습니다.'); + return; + } + + // 행의 두 번째 셀()에서 역할 텍스트를 가져옵니다. + const roleText = row.cells[1] ? row.cells[1].textContent : '알 수 없는 역할'; + + // 사용자에게 삭제 확인을 받습니다. + if (confirm(`정말로 이 모델 파일을 삭제하시겠습니까?\n\n역할: ${roleText}\n파일: ${filename}`)) { + // 확인 시, 삭제 함수 호출 + deleteModelFile(filename); + } + } + }); + } + // ========== 삭제 버튼 이벤트 리스너 끝 ========== + } - // ========== AI 모델 업로드 로직 끝 ========== + // ========== AI 모델 업로드/관리 로직 끝 ========== - // ========== 추가된 부분 시작 ========== - // 로그아웃 버튼 처리 + // ========== 로그아웃 버튼 처리 ========== const logoutButton = document.getElementById('logout-button'); if (logoutButton) { // 로그아웃 버튼이 있는지 확인 @@ -133,7 +254,7 @@ document.addEventListener('DOMContentLoaded', () => { window.location.href = '/'; }); } - // ========== 추가된 부분 끝 ========== + // ========== 로그아웃 버튼 처리 끝 ========== // ========== video-test.html 스크립트 추가 시작 ========== @@ -143,36 +264,29 @@ document.addEventListener('DOMContentLoaded', () => { const frameInfoEl = document.getElementById("frame-info"); const detListEl = document.getElementById("det-list"); const bboxContainerEl = document.getElementById("bbox-container"); - - // [수정] 1. 'current-model' select 대신 'current-model-container' div 요소를 가져옵니다. const modelContainerEl = document.getElementById("current-model-container"); let lastFrameMeta = null; - // [수정] 2. 비디오 관련 요소 확인 if문에 modelContainerEl을 추가합니다. if (imgEl && statusEl && frameInfoEl && detListEl && bboxContainerEl && modelContainerEl) { - // [수정] 3. 모델 변경 이벤트 리스너 추가 (이벤트 위임 사용) + // 모델 변경 이벤트 리스너 modelContainerEl.addEventListener('change', (event) => { - // 이벤트가 'name'이 'current-model'인 라디오 버튼에서 발생했는지 확인 if (event.target && event.target.name === 'current-model') { - const selectedModel = event.target.value; // 선택된 라디오 버튼의 값 + const selectedModel = event.target.value; console.log(`Model changed to: ${selectedModel}`); - // 서버에 /set-model 엔드포인트로 POST 요청 전송 fetch('/set-model', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ model: selectedModel }), // { "model": "OBJDET" } + body: JSON.stringify({ model: selectedModel }), }) .then(response => response.json()) .then(data => { console.log('Server response:', data); - // 서버 응답에 따라 상태 메시지 업데이트 if (data.status === 'success') { - // logStatus 함수가 아래에 정의되어 있으므로 사용 가능 logStatus(`모델 변경 완료: ${selectedModel}`); } else { logStatus(`모델 변경 실패: ${data.message}`, true); @@ -184,12 +298,10 @@ document.addEventListener('DOMContentLoaded', () => { }); } }); - // [수정] 모델 변경 리스너 끝 - // [기존] 박스 컨테이너 위치/크기 조절 함수 + // 박스 컨테이너 위치/크기 조절 함수 function updateBboxContainerPosition() { if (!imgEl || !bboxContainerEl) return; - // ... (기존 코드와 동일) const top = imgEl.offsetTop; const left = imgEl.offsetLeft; const width = imgEl.offsetWidth; @@ -200,7 +312,6 @@ document.addEventListener('DOMContentLoaded', () => { bboxContainerEl.style.height = `${height}px`; } - function logStatus(msg, isError = false) { statusEl.textContent = msg; statusEl.className = "status" + (isError ? " err" : ""); @@ -211,9 +322,8 @@ document.addEventListener('DOMContentLoaded', () => { ` | FRAME ch=${meta.ch} ts=${meta.ts_us} w=${meta.w} h=${meta.h}`; } - // [기존] showDetections 함수 + // Detections 표시 함수 function showDetections(meta) { - // ... (기존 코드와 동일) ... const items = meta.items || []; let lines = []; lines.push(`DET ch=${meta.ch} seq=${meta.seq} ts=${meta.ts_us} cnt=${items.length}`); @@ -276,9 +386,8 @@ document.addEventListener('DOMContentLoaded', () => { detListEl.textContent = lines.join("\n"); } - // [기존] connect 함수 + // WebSocket 연결 함수 function connect() { - // ... (기존 코드와 동일) ... const ws = new WebSocket(uri); ws.binaryType = "arraybuffer"; ws.onopen = () => { logStatus(`연결됨: ${uri}`); }; @@ -322,11 +431,14 @@ document.addEventListener('DOMContentLoaded', () => { // WebSocket 연결 시작 connect(); - // [기존] 창 크기 변경 시 이벤트 + // 창 크기 변경 시 이벤트 window.addEventListener('resize', updateBboxContainerPosition); } // if (요소 확인) 끝 // ========== video-test.html 스크립트 추가 끝 ========== + + // ========== 페이지 로드 시 모델 목록 즉시 로드 ========== + loadModelList(); + }); -// \ No newline at end of file diff --git a/public/dashboard.html b/public/dashboard.html index d3aa821..e9a139a 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -71,8 +71,8 @@ 선택된 파일 없음 - - + + @@ -82,42 +82,47 @@ - + + - + + - + - - + + + - - - - + + + + - + - - + + + - + - + + @@ -129,5 +134,4 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/public/index.html b/public/index.html index ce515be..0fe91fe 100644 --- a/public/index.html +++ b/public/index.html @@ -8,17 +8,58 @@
-

Welcome to Al Drone System v1.0.875

-
+

Al Drone Console v1.0

+ + +
- +
- +
+ + \ No newline at end of file diff --git a/public/style.css b/public/style.css index e18b991..a5f7a79 100644 --- a/public/style.css +++ b/public/style.css @@ -376,4 +376,4 @@ main { border: 2px solid red; /* 요청된 빨간색 테두리 */ box-sizing: border-box; /* 테두리 포함 크기 계산 */ } -/**/ \ No newline at end of file +/**/ \ No newline at end of file diff --git a/server.js b/server.js index 2039db3..55d9f1b 100644 --- a/server.js +++ b/server.js @@ -1,36 +1,31 @@ -// server.js - // 1. 모듈 불러오기 const express = require('express'); const path = require('path'); const { spawn } = require('child_process'); -const multer = require('multer'); // [추가] 파일 업로드 처리를 위한 multer -const fs = require('fs'); // [추가] 파일 시스템 접근을 위한 fs +const multer = require('multer'); // 파일 업로드 처리를 위한 multer +const fs = require('fs'); // 파일 시스템 접근을 위한 fs // 2. Express 앱 생성 const app = express(); const port = 3000; -// [추가] 업로드 경로 설정 +// 업로드 경로 설정 const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva'; -// [추가] 업로드 경로가 없으면 생성 -// (실제 운영 환경에서는 권한 문제가 없는지 확인 필요) +// 업로드 경로가 없으면 생성 fs.mkdirSync(uploadPath, { recursive: true }); -// [추가] Multer 저장소 설정 +// Multer 저장소 설정 const storage = multer.diskStorage({ - // 파일 저장 위치 지정 destination: (req, file, cb) => { cb(null, uploadPath); }, - // 파일 이름 지정 (원본 파일 이름 사용) filename: (req, file, cb) => { cb(null, file.originalname); } }); -// [추가] Multer 파일 필터 설정 (.aiwbin 확장자만 허용) +// Multer 파일 필터 설정 (.aiwbin 확장자만 허용) const fileFilter = (req, file, cb) => { if (!file.originalname.endsWith('.aiwbin')) { // 허용되지 않는 파일 형식 @@ -40,7 +35,7 @@ const fileFilter = (req, file, cb) => { cb(null, true); }; -// [추가] Multer 업로드 인스턴스 생성 +// Multer 업로드 인스턴스 생성 const upload = multer({ storage: storage, fileFilter: fileFilter @@ -50,7 +45,7 @@ const upload = multer({ // 3. 'public' 폴더의 파일들을 정적 파일로 제공하도록 설정합니다. app.use(express.static(path.join(__dirname, 'public'))); -// [추가] POST 요청의 JSON 본문(body)을 파싱하기 위한 미들웨어 +// POST 요청의 JSON 본문(body)을 파싱하기 위한 미들웨어 app.use(express.json()); // 4. 루트 경로('/')로 접속하면 'index.html'(로그인 페이지)를 보냅니다. @@ -63,7 +58,7 @@ app.get('/dashboard', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'dashboard.html')); }); -// [수정] 6. 모델 변경 API 엔드포인트 +// 6. 모델 변경 API 엔드포인트 app.post('/set-model', (req, res) => { const { model } = req.body; // app.js에서 보낸 { model: "..." } @@ -73,49 +68,38 @@ app.post('/set-model', (req, res) => { console.log(`[서버] 모델 변경 명령 수신: ${model}`); - // --- 요청하신 파이썬 스크립트 실행 --- const pythonCommand = 'python3'; const scriptPath = '/mnt/user_data/ctrl_cli.py'; - - // [수정] 인수를 "ON|OFF [선택값]" 형태의 단일 문자열로 만듭니다. const combinedArg = `"ON ${model}"`; - - // [수정] 수정된 인수를 배열에 담아 전달합니다. const args = [scriptPath, combinedArg]; - console.log(`[서버] 실행: ${pythonCommand} ${args.join(' ')}`); // 실행될 명령어 로그 + console.log(`[서버] 실행: ${pythonCommand} ${args.join(' ')}`); const py = spawn(pythonCommand, args); let stdoutData = ''; let stderrData = ''; - // 파이썬 스크립트의 표준 출력 py.stdout.on('data', (data) => { console.log(`Python stdout: ${data}`); stdoutData += data.toString(); }); - // 파이썬 스크립트의 표준 에러 py.stderr.on('data', (data) => { console.error(`Python stderr: ${data}`); stderrData += data.toString(); }); - // 파이썬 프로세스 종료 py.on('close', (code) => { console.log(`Python process exited with code ${code}`); if (code === 0) { - // 성공 시 클라이언트에 응답 res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData }); } else { - // 실패 시 클라이언트에 에러 응답 res.status(500).json({ status: 'error', message: '파이썬 스크립트 실행 실패', error: stderrData }); } }); - // 스폰 자체의 에러 (예: python3 명령어를 찾을 수 없음) py.on('error', (err) => { console.error('[서버] 파이썬 프로세스 시작 실패:', err); res.status(500).json({ status: 'error', message: '프로세스 시작 실패', error: err.message }); @@ -123,39 +107,28 @@ app.post('/set-model', (req, res) => { // --- 스크립트 실행 끝 --- }); -// [수정됨] 7. 모델 파일 업로드 엔드포인트 -// 'modelFile'은 app.js의 FormData.append('modelFile', ...)와 일치해야 합니다. +// 7. 모델 파일 업로드 엔드포인트 app.post('/upload-model', (req, res) => { - // upload.single() 미들웨어를 수동으로 호출하여 오류를 상세히 처리 upload.single('modelFile')(req, res, function (err) { - // Multer 관련 오류 처리 (예: 파일 크기 제한, 저장소 오류 등) if (err instanceof multer.MulterError) { console.error('[Multer Error]:', err.message); return res.status(500).json({ status: 'error', message: `업로드 오류: ${err.message}` }); } - // 파일 필터에서 발생한 오류 처리 (예: .aiwbin이 아닌 경우) else if (err) { console.error('[File Filter Error]:', err.message); return res.status(400).json({ status: 'error', message: err.message }); } - // 파일이 제대로 업로드되었는지 확인 if (!req.file) { return res.status(400).json({ status: 'error', message: '업로드할 파일을 찾을 수 없습니다.' }); } - // [제거됨] 폼 데이터로 함께 전송된 모델 정보 (modelId, modelRole) - // const modelId = req.body.modelId; - // const modelRole = req.body.modelRole; - console.log(`[서버] 파일 업로드 성공:`); - // [제거됨] console.log(` - 모델 ID: ${modelId} (${modelRole})`); console.log(` - 원본 파일명: ${req.file.originalname}`); console.log(` - 저장 경로: ${req.file.path}`); - // 클라이언트에 성공 응답 전송 res.json({ status: 'success', message: `파일 '${req.file.originalname}'이(가) 성공적으로 업로드되었습니다.`, @@ -164,9 +137,111 @@ app.post('/upload-model', (req, res) => { }); }); +// ========== 모델 목록 조회 로직 시작 ========== + +/** + * 버전 문자열을 비교합니다. (예: "v1.1" > "v1.0") + */ +function isVersionGreater(v1, v2) { + if (!v2) return true; + + const parts1 = v1.replace('v', '').split('.').map(Number); + const parts2 = v2.replace('v', '').split('.').map(Number); + + if (parts1[0] > parts2[0]) return true; + if (parts1[0] < parts2[0]) return false; + + if (parts1[1] > parts2[1]) return true; + + return false; +} + +// 7-1. 모델 목록 조회 엔드포인트 +app.get('/list-models', (req, res) => { + const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/; + const latestModels = {}; + + fs.readdir(uploadPath, (err, files) => { + if (err) { + console.error('[서버] 모델 디렉토리 읽기 실패:', err); + return res.status(500).json({ status: 'error', message: '서버에서 파일 목록을 읽을 수 없습니다.' }); + } + + files.forEach(file => { + const match = file.match(modelRegex); + + if (match) { + const role = match[1]; + const version = `v${match[2]}`; + + const existing = latestModels[role]; + + if (!existing || isVersionGreater(version, existing.version)) { + latestModels[role] = { + file: file, + version: version + }; + } + } + }); + + console.log('[서버] 최신 모델 목록 조회:', latestModels); + res.json(latestModels); + }); +}); +// ========== 모델 목록 조회 로직 끝 ========== + + +// ========== [추가됨] 모델 파일 삭제 엔드포인트 ========== +app.post('/delete-model', (req, res) => { + const { filename } = req.body; // { "filename": "CUUVA_..." } + + if (!filename) { + return res.status(400).json({ status: 'error', message: '파일 이름이 제공되지 않았습니다.' }); + } + + // [보안] Directory Traversal 공격 방지 + // path.basename은 파일명에서 상위 디렉토리(.., /) 등을 제거합니다. + const safeFilename = path.basename(filename); + + // 만약 ".." 같은 것이 포함되어 safeFilename이 원래 filename과 다르다면, 비정상 요청 + if (safeFilename !== filename) { + return res.status(400).json({ status: 'error', message: '잘못된 파일 이름 형식입니다.' }); + } + + // [보안] .aiwbin 파일만 삭제하도록 다시 한번 확인 + if (!safeFilename.endsWith('.aiwbin')) { + return res.status(400).json({ status: 'error', message: '.aiwbin 파일만 삭제할 수 있습니다.' }); + } + + // 최종 삭제할 파일 경로 + const filePath = path.join(uploadPath, safeFilename); + + console.log(`[서버] 파일 삭제 시도: ${filePath}`); + + // fs.unlink로 파일 삭제 + fs.unlink(filePath, (err) => { + if (err) { + // 파일이 존재하지 않는 경우 (이미 삭제되었거나, 잘못된 요청) + if (err.code === 'ENOENT') { + console.error(`[서버] 삭제 실패: 파일 없음 ${filePath}`, err); + return res.status(404).json({ status: 'error', message: '삭제할 파일을 서버에서 찾을 수 없습니다.' }); + } + // 기타 오류 (예: 권한 문제) + console.error(`[서버] 파일 삭제 오류 ${filePath}:`, err); + return res.status(500).json({ status: 'error', message: '파일 삭제 중 서버 오류가 발생했습니다.' }); + } + + // 삭제 성공 + console.log(`[서버] 파일 삭제 성공: ${filePath}`); + res.json({ status: 'success', message: `파일 '${safeFilename}'(이)가 성공적으로 삭제되었습니다.` }); + }); +}); +// ========== 모델 파일 삭제 엔드포인트 끝 ========== + -// 8. (번호 수정) 설정한 3000번 포트에서 서버가 요청을 기다리도록 실행합니다. +// 8. 설정한 3000번 포트에서 서버가 요청을 기다리도록 실행합니다. app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); - \ No newline at end of file +//``` \ No newline at end of file
파일명 현재 버전 삭제
1 객체 탐지/ 분류- v1.0-v1.0
2이상행동(쓰러짐, 폭행) 감지- v1.0화재(불꽃, 연기) 감지-v1.0
3집합 군중 위험 인식- v1.0
3이상행동(쓰러짐, 폭행) 감지-v1.0
4화재(불꽃, 연기) 감지- -얼굴/ 인상착의 인식--
5 차량 번호판/ 차종 인식- ---