Refactor: Transition to Integrated Management Platform (DB Separation) v0.1.0
This commit is contained in:
parent
f5f7086fbf
commit
a771fc0561
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "temp_app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -42,4 +42,4 @@
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
server/add_is_active_column.js
Normal file
38
server/add_is_active_column.js
Normal file
@ -0,0 +1,38 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config(); // Load .env from current directory
|
||||
|
||||
async function addColumn() {
|
||||
process.env.DB_HOST = process.env.DB_HOST || 'localhost';
|
||||
process.env.DB_USER = process.env.DB_USER || 'root';
|
||||
process.env.DB_PASSWORD = process.env.DB_PASSWORD || 'password'; // Fallback or assume env is loaded
|
||||
process.env.DB_NAME = process.env.DB_NAME || 'smartasset_db';
|
||||
|
||||
console.log(`Connecting to database: ${process.env.DB_NAME} at ${process.env.DB_HOST}`);
|
||||
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT || 3306
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("Checking if 'is_active' column exists...");
|
||||
const [rows] = await connection.execute("SHOW COLUMNS FROM camera_settings LIKE 'is_active'");
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("Adding 'is_active' column...");
|
||||
await connection.execute("ALTER TABLE camera_settings ADD COLUMN is_active BOOLEAN DEFAULT TRUE");
|
||||
console.log("Column 'is_active' added successfully!");
|
||||
} else {
|
||||
console.log("Column 'is_active' already exists.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating database:", error);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
addColumn();
|
||||
@ -357,12 +357,17 @@ app.post('/api/upload', upload.single('image'), (req, res) => {
|
||||
app.use('/api', require('./modules/asset/routes'));
|
||||
|
||||
// Serve Frontend Static Files (Production/Deployment)
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
// Strict Sibling Structure: Expects 'dist' to be a sibling of 'server'
|
||||
// This matches the local development environment structure.
|
||||
const distPath = path.join(__dirname, '../dist');
|
||||
|
||||
console.log(`Serving static files from: ${distPath}`);
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// The "catchall" handler: for any request that doesn't
|
||||
// match one above, send back React's index.html file.
|
||||
app.get(/(.*)/, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
|
||||
53
server/migrate_db.js
Normal file
53
server/migrate_db.js
Normal file
@ -0,0 +1,53 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
const SOURCE_DB = 'smart_asset_db';
|
||||
const TARGET_DBS = ['sokuree_platform_dev', 'sokuree_platform_prod'];
|
||||
|
||||
async function migrate() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT || 3306
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`Connected to MySQL at ${process.env.DB_HOST}`);
|
||||
|
||||
for (const targetDb of TARGET_DBS) {
|
||||
console.log(`\nProcessing Target DB: ${targetDb}`);
|
||||
|
||||
// 1. Create Database
|
||||
console.log(`Creating database ${targetDb} if not exists...`);
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS ${targetDb}`);
|
||||
|
||||
// 2. Get Tables from Source
|
||||
const [tables] = await connection.query(`SHOW TABLES FROM ${SOURCE_DB}`);
|
||||
const tableNames = tables.map(row => Object.values(row)[0]);
|
||||
|
||||
for (const table of tableNames) {
|
||||
console.log(` Migrating table: ${table}...`);
|
||||
|
||||
// 3. Create Table (Structure)
|
||||
// Drop if exists to ensure clean state or handle updates?
|
||||
// Let's drop for now to ensure exact clone.
|
||||
await connection.query(`DROP TABLE IF EXISTS ${targetDb}.${table}`);
|
||||
await connection.query(`CREATE TABLE ${targetDb}.${table} LIKE ${SOURCE_DB}.${table}`);
|
||||
|
||||
// 4. Copy Data
|
||||
await connection.query(`INSERT INTO ${targetDb}.${table} SELECT * FROM ${SOURCE_DB}.${table}`);
|
||||
console.log(` -> Data copied.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nMigration completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@ -91,6 +91,27 @@ router.put('/:id', hasRole('admin'), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle Stream Status (Admin only)
|
||||
router.patch('/:id/status', hasRole('admin'), async (req, res) => {
|
||||
const { is_active } = req.body;
|
||||
try {
|
||||
const [result] = await db.query('UPDATE camera_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||
|
||||
const streamRelay = req.app.get('streamRelay');
|
||||
if (streamRelay) {
|
||||
// If disabled, stop stream. If enabled, reset (which stops and allows reconnect)
|
||||
console.log(`Stream status changed for camera ${req.params.id} to ${is_active}`);
|
||||
streamRelay.resetStream(req.params.id);
|
||||
}
|
||||
|
||||
res.json({ message: `Stream ${is_active ? 'enabled' : 'disabled'}` });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete camera (Admin only)
|
||||
router.delete('/:id', hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -1,55 +1,11 @@
|
||||
const WebSocket = require('ws');
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity
|
||||
const db = require('../../db');
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Determine FFmpeg path: Prefer system install on Synology, fallback to static
|
||||
// Determine FFmpeg path: Prioritize Community packages (RTSP support) > System (limited) > Static
|
||||
let systemFfmpegPath = ffmpegPath;
|
||||
|
||||
// Potential paths for fully-featured FFmpeg (SynoCommunity, etc.)
|
||||
// User 'find /usr' showed it's NOT in /usr/local/bin, so we must look in package dirs.
|
||||
const priorityPaths = [
|
||||
// SynoCommunity or Package Center standard paths
|
||||
'/var/packages/ffmpeg/target/bin/ffmpeg',
|
||||
'/var/packages/ffmpeg7/target/bin/ffmpeg',
|
||||
'/var/packages/ffmpeg6/target/bin/ffmpeg',
|
||||
|
||||
// Direct volume paths (common on Synology)
|
||||
'/volume1/@appstore/ffmpeg/bin/ffmpeg',
|
||||
'/volume1/@appstore/ffmpeg7/bin/ffmpeg',
|
||||
'/volume1/@appstore/ffmpeg6/bin/ffmpeg',
|
||||
|
||||
// Standard Linux paths
|
||||
'/usr/local/bin/ffmpeg',
|
||||
'/usr/bin/ffmpeg',
|
||||
'/bin/ffmpeg'
|
||||
];
|
||||
|
||||
let foundPath = false;
|
||||
for (const path of priorityPaths) {
|
||||
if (fs.existsSync(path)) {
|
||||
systemFfmpegPath = path;
|
||||
console.log(`[StreamRelay] Found FFmpeg at: ${path}`);
|
||||
// If we found a path that is NOT the system default (which we know is broken), likely we are good.
|
||||
// But even if it is system default, we use it if nothing else found.
|
||||
if (path !== '/usr/bin/ffmpeg' && path !== '/bin/ffmpeg') {
|
||||
console.log('[StreamRelay] Using generic/community FFmpeg (likely supports RTSP).');
|
||||
} else {
|
||||
console.warn('[StreamRelay] WARNING: Using system FFmpeg. RTSP might fail ("Protocol not found").');
|
||||
}
|
||||
foundPath = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundPath) {
|
||||
console.warn('[StreamRelay] No FFmpeg binary found in priority paths. Falling back to ffmpeg-static or default PATH.');
|
||||
}
|
||||
|
||||
ffmpeg.setFfmpegPath(systemFfmpegPath);
|
||||
// We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically
|
||||
// (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness)
|
||||
|
||||
class StreamRelay {
|
||||
constructor(server) {
|
||||
@ -74,8 +30,6 @@ class StreamRelay {
|
||||
}
|
||||
|
||||
parseCameraId(url) {
|
||||
// url format: /api/stream?cameraId=1 or /api/stream/1 (if path matching works that way, but ws path is fixed)
|
||||
// Let's use query param: ws://host/api/stream?cameraId=1
|
||||
try {
|
||||
const params = new URLSearchParams(url.split('?')[1]);
|
||||
return params.get('cameraId');
|
||||
@ -104,19 +58,15 @@ class StreamRelay {
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully disconnect all clients to trigger reconnection
|
||||
resetStream(cameraId) {
|
||||
const stream = this.streams.get(cameraId);
|
||||
if (stream) {
|
||||
console.log(`Resetting stream for camera ${cameraId} (closing ${stream.clients.size} clients)...`);
|
||||
// Close all clients. This will trigger 'close' event on ws,
|
||||
// calling removeClient -> stopStream.
|
||||
for (const client of stream.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.close(1000, "Stream Reset");
|
||||
}
|
||||
}
|
||||
// Ensure stream is stopped even if no clients existed
|
||||
this.stopStream(cameraId);
|
||||
}
|
||||
}
|
||||
@ -133,7 +83,17 @@ class StreamRelay {
|
||||
}
|
||||
const camera = rows[0];
|
||||
|
||||
// Construct RTSP URL
|
||||
// Check if streaming is enabled
|
||||
// Ignore if IGNORE_CAMERA_STATUS is explicitly 'true' (for local dev)
|
||||
if ((camera.is_active === 0 || camera.is_active === false)) {
|
||||
if (process.env.IGNORE_CAMERA_STATUS === 'true') {
|
||||
console.log(`[DEV OVERRIDE] Streaming disabled in DB for camera ${cameraId}, but forced ON by IGNORE_CAMERA_STATUS.`);
|
||||
} else {
|
||||
console.log(`Streaming is disabled for camera ${cameraId} (${camera.name}). skipping FFmpeg start.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let rtspUrl = 'rtsp://';
|
||||
if (camera.username && camera.password) {
|
||||
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username;
|
||||
@ -144,71 +104,85 @@ class StreamRelay {
|
||||
|
||||
console.log(`Starting FFmpeg for camera ${cameraId} (${camera.name})...`);
|
||||
|
||||
const transportMode = camera.transport_mode === 'udp' ? 'udp' : 'tcp';
|
||||
// 1. Determine FFmpeg Binary Path (v1.0.8 Fix)
|
||||
let selectedFfmpegPath;
|
||||
if (process.env.FORCE_STATIC_FFMPEG === 'true') {
|
||||
selectedFfmpegPath = staticFfmpegBinary;
|
||||
} else {
|
||||
const systemFfmpegPath = '/var/packages/ffmpeg7/target/bin/ffmpeg';
|
||||
// Check system paths, priority to Synology packages, fallback to static
|
||||
if (fs.existsSync(systemFfmpegPath)) {
|
||||
selectedFfmpegPath = systemFfmpegPath;
|
||||
} else if (fs.existsSync('/var/packages/ffmpeg6/target/bin/ffmpeg')) {
|
||||
selectedFfmpegPath = '/var/packages/ffmpeg6/target/bin/ffmpeg';
|
||||
} else {
|
||||
selectedFfmpegPath = staticFfmpegBinary;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine Quality Scaling
|
||||
// Set for this command (Note: fluent-ffmpeg setFfmpegPath is global, but usually fine if concurrent calls settle on same binary or if we just set it before command)
|
||||
ffmpeg.setFfmpegPath(selectedFfmpegPath);
|
||||
|
||||
// 2. Transport Mode
|
||||
const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp';
|
||||
|
||||
// 3. Unique User-Agent to prevent session conflicts
|
||||
const userAgent = `SmartAsset-Relay-v1.0.8-${transportMode}`;
|
||||
|
||||
// Quality Scaling
|
||||
let scaleFilter = [];
|
||||
let videoBitrate = '1000k';
|
||||
|
||||
const qual = camera.quality || 'low';
|
||||
|
||||
if (qual === 'low') {
|
||||
scaleFilter = ['-vf scale=640:-1'];
|
||||
videoBitrate = '1000k';
|
||||
scaleFilter = ['-vf scale=1280:-1']; // Using 720p even for low for better visibility, bitrate controls size
|
||||
videoBitrate = '2000k';
|
||||
} else if (qual === 'medium') {
|
||||
scaleFilter = ['-vf scale=1280:-1'];
|
||||
videoBitrate = '2500k';
|
||||
videoBitrate = '4000k';
|
||||
} else {
|
||||
// Original - Restore high bitrate as requested
|
||||
videoBitrate = '6000k';
|
||||
}
|
||||
|
||||
// Compatibility: Synology FFmpeg might not support -rtsp_transport flag or strict ordering
|
||||
// Try removing explicit transport mode if it fails, or rely on Auto.
|
||||
const inputOptions = [];
|
||||
const inputOptions = [
|
||||
'-user_agent', userAgent
|
||||
];
|
||||
if (transportMode !== 'auto') {
|
||||
// We restored this because we are now ensuring a proper FFmpeg version (v6/7 or static) is used.
|
||||
// REVERT: User reported local Windows issues with this flag enforced.
|
||||
// Commenting out to restore local functionality. Synology should still work via Auto or default.
|
||||
// inputOptions.push(`-rtsp_transport ${transportMode}`);
|
||||
inputOptions.push('-rtsp_transport', transportMode);
|
||||
}
|
||||
|
||||
console.log(`[StreamRelay] Using FFmpeg binary: ${systemFfmpegPath}`);
|
||||
console.log(`[StreamRelay v1.0.8] Using FFmpeg binary: ${selectedFfmpegPath}`);
|
||||
console.log(`[StreamRelay v1.0.10] Transport Mode: ${transportMode} (Override: ${process.env.CCTV_TRANSPORT_OVERRIDE || 'none'})`);
|
||||
console.log(`[StreamRelay v1.0.8] User-Agent: ${userAgent}`);
|
||||
console.log(`[StreamRelay v1.0.8] Input Options:`, inputOptions);
|
||||
|
||||
const command = ffmpeg(rtspUrl)
|
||||
.inputOptions(inputOptions)
|
||||
// .inputOptions([
|
||||
// `-rtsp_transport ${transportMode}`
|
||||
// ])
|
||||
.addOptions([
|
||||
'-c:v mpeg1video', // Video codec for JSMpeg
|
||||
'-f mpegts', // Output format
|
||||
'-codec:a mp2', // Audio codec
|
||||
`-b:v ${videoBitrate}`, // Dynamic Video bitrate
|
||||
`-maxrate ${videoBitrate}`, // Cap max bitrate
|
||||
`-bufsize ${videoBitrate}`,
|
||||
'-r 30', // FPS 30 (Restored)
|
||||
'-g 30', // GOP size 30 (1 keyframe per sec)
|
||||
'-bf 0', // No B-frames
|
||||
...scaleFilter
|
||||
])
|
||||
const command = ffmpeg(rtspUrl);
|
||||
if (inputOptions.length > 0) {
|
||||
command.inputOptions(inputOptions);
|
||||
}
|
||||
|
||||
command.addOptions([
|
||||
'-c:v mpeg1video',
|
||||
'-f mpegts',
|
||||
'-codec:a mp2',
|
||||
`-b:v ${videoBitrate}`,
|
||||
`-maxrate ${videoBitrate}`,
|
||||
`-bufsize ${videoBitrate}`,
|
||||
'-r 30',
|
||||
'-g 30',
|
||||
'-bf 0',
|
||||
...scaleFilter
|
||||
])
|
||||
.on('start', (cmdLine) => {
|
||||
console.log('FFmpeg started:', cmdLine);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
// Only log if not killed manually (checking active stream usually tricky, but error msg usually enough)
|
||||
if (!err.message.includes('SIGKILL')) {
|
||||
console.error('FFmpeg error:', err.message);
|
||||
// If error occurs, maybe stop stream?
|
||||
// For reload, we might retry? simple stop for now.
|
||||
this.stopStream(cameraId);
|
||||
}
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('FFmpeg exited');
|
||||
// We don't indiscriminately stopStream here because 'reload' causes an exit too.
|
||||
// But 'stopStream' checks if process matches?
|
||||
// Let's rely on the process being replaced or nullified.
|
||||
});
|
||||
|
||||
stream.process = command;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@ -27,4 +27,4 @@
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ interface Camera {
|
||||
rtsp_encoding?: boolean;
|
||||
quality?: 'low' | 'medium' | 'original';
|
||||
display_order?: number;
|
||||
is_active?: boolean | number;
|
||||
}
|
||||
|
||||
// Wrap Camera Card with Sortable
|
||||
@ -138,6 +139,31 @@ export function MonitoringPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (camera: Camera) => {
|
||||
// Optimistic UI Update: Calculate new status
|
||||
const currentStatus = camera.is_active !== 0 && camera.is_active !== false;
|
||||
const newStatus = !currentStatus;
|
||||
|
||||
// Immediately update UI state
|
||||
setCameras(prev => prev.map(c =>
|
||||
c.id === camera.id ? { ...c, is_active: newStatus ? 1 : 0 } : c
|
||||
));
|
||||
|
||||
try {
|
||||
// Send request to server
|
||||
await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus });
|
||||
// Only silent fetch to sync eventually, no alert needed
|
||||
fetchCameras();
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle status', err);
|
||||
// Revert UI on failure
|
||||
setCameras(prev => prev.map(c =>
|
||||
c.id === camera.id ? { ...c, is_active: currentStatus ? 1 : 0 } : c
|
||||
));
|
||||
alert('상태 변경 실패 (되돌리기)');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@ -254,6 +280,17 @@ export function MonitoringPage() {
|
||||
{/* Overlay Controls */}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(camera)}
|
||||
className={`${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-orange-500/80 hover:bg-orange-600' : 'bg-green-600/80 hover:bg-green-700'} text-white p-2 rounded-full`}
|
||||
title={(camera.is_active !== 0 && camera.is_active !== false) ? "스트리밍 중지 (Pause)" : "스트리밍 시작 (Play)"}
|
||||
>
|
||||
{(camera.is_active !== 0 && camera.is_active !== false) ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePing(camera.id)}
|
||||
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
|
||||
@ -279,8 +316,8 @@ export function MonitoringPage() {
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<h3 className="text-white font-medium flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
{camera.name}
|
||||
<span className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
|
||||
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && <span className="text-xs text-slate-400">(중지됨)</span>}
|
||||
</h3>
|
||||
{/* IP/Port Hidden as requested */}
|
||||
</div>
|
||||
@ -314,77 +351,19 @@ export function MonitoringPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">카메라 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="예: 정문 CCTV"
|
||||
/>
|
||||
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IP 주소</label>
|
||||
<input
|
||||
type="text"
|
||||
name="ip_address"
|
||||
value={formData.ip_address}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">포트</label>
|
||||
<input
|
||||
type="number"
|
||||
name="port"
|
||||
value={formData.port}
|
||||
onChange={handleInputChange}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="554"
|
||||
/>
|
||||
</div>
|
||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP 주소</label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
|
||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">포트</label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">RTSP 사용자명</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">RTSP 비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP 사용자명</label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
|
||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP 비밀번호</label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">스트림 경로</label>
|
||||
<input
|
||||
type="text"
|
||||
name="stream_path"
|
||||
value={formData.stream_path}
|
||||
onChange={handleInputChange}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="/stream1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
TAPO C200 예시: /stream1 (고화질) 또는 /stream2 (저화질)
|
||||
</p>
|
||||
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
@ -395,12 +374,7 @@ export function MonitoringPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">전송 방식 (Transport)</label>
|
||||
<select
|
||||
name="transport_mode"
|
||||
value={formData.transport_mode || 'tcp'}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option value="tcp">TCP (권장 - 안정적)</option>
|
||||
<option value="udp">UDP (빠름 - 끊김가능)</option>
|
||||
<option value="auto">Auto</option>
|
||||
@ -408,50 +382,24 @@ export function MonitoringPage() {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rtsp_encoding"
|
||||
checked={!!formData.rtsp_encoding}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
|
||||
<span className="text-sm text-slate-600">특수문자 인코딩 사용</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">스트림 화질 (Quality)</label>
|
||||
<select
|
||||
name="quality"
|
||||
value={formData.quality || 'low'}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option value="low">Low (640px) - 빠르고 안정적 (권장)</option>
|
||||
<option value="medium">Medium (1280px) - HD 화질</option>
|
||||
<option value="original">Original - 원본 화질 (네트워크 부하 큼)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
* 연결이 불안정하면 TCP로 설정하세요. 비밀번호에 특수문자가 있어 연결 실패 시 인코딩을 켜보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? '저장 중...' : '저장하기'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition">취소</button>
|
||||
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user