Initial commit: IP Monitor - Modern Windows Desktop Application
Features: - IP/Country detection from public APIs - M3U8 playlist parsing and channel management - Single URL channel support - Live stream monitoring with HLS.js video player - Ping/TCP health checks - Stream error detection (CONNRESET, 404, timeout) - Windows Toast notifications - Telegram Bot notifications - Email (SMTP) notifications - Auto/Manual notification options - MONITOR_API_GUIDE panel integration - System tray support - Persistent settings (portable mode support) - Modern dark UI with Tailwind CSS Tech Stack: - Electron 28 - React 18 + TypeScript - Vite - Tailwind CSS + Framer Motion - HLS.js for video playback - electron-store for persistent data
This commit is contained in:
commit
adb574e68e
|
|
@ -0,0 +1,34 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
release/
|
||||
*.exe
|
||||
*.dmg
|
||||
*.AppImage
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Electron Store data
|
||||
config.json
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# IP Monitor
|
||||
|
||||
Modern Windows masaüstü uygulaması - IP izleme, m3u8 kanal kontrolü ve bildirim sistemi.
|
||||
|
||||
## Özellikler
|
||||
|
||||
### 🌐 IP Bilgileri
|
||||
- Public IP adresinizi otomatik tespit
|
||||
- Ülke, şehir ve ISP bilgilerini görüntüleme
|
||||
- ip-api.com ve ipify.org API entegrasyonu
|
||||
|
||||
### 📺 M3U8 Kanal Yönetimi
|
||||
- M3U8/M3U playlist URL'lerini içe aktarma
|
||||
- Kanal listesi görüntüleme ve gruplandırma
|
||||
- İzlemek istediğiniz kanalları seçme
|
||||
- Kanal logolarını ve metadata'sını gösterme
|
||||
|
||||
### 📊 Canlı İzleme
|
||||
- Seçili kanalların ping ve stream kontrolü
|
||||
- HTTP durum kodları takibi (404, 502, vb.)
|
||||
- Bağlantı hataları tespiti (ERR_CONNRESET, ECONNREFUSED)
|
||||
- Gerçek zamanlı durum güncellemeleri
|
||||
- Gecikme (latency) ölçümü
|
||||
|
||||
### 🔔 Bildirim Sistemi
|
||||
- **Windows Toast**: Yerel Windows bildirimleri
|
||||
- **Telegram Bot**: Telegram üzerinden anlık bildirim
|
||||
- **Email (SMTP)**: E-posta ile hata raporları
|
||||
- Otomatik veya manuel bildirim seçeneği
|
||||
- Özelleştirilebilir bildirim kuralları
|
||||
|
||||
### 🔧 Panel Entegrasyonu
|
||||
- MONITOR_API_GUIDE uyumlu API entegrasyonu
|
||||
- IP listesi çekme (REST/SSE)
|
||||
- Burn vote gönderme
|
||||
- Health check raporlama
|
||||
|
||||
## Kurulum
|
||||
|
||||
```bash
|
||||
# Bağımlılıkları yükle
|
||||
npm install
|
||||
|
||||
# Geliştirme modunda çalıştır
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
|
||||
# Electron uygulaması oluştur
|
||||
npm run build:electron
|
||||
```
|
||||
|
||||
## Gereksinimler
|
||||
|
||||
- Node.js 18+
|
||||
- npm veya yarn
|
||||
- Windows 10/11 (64-bit)
|
||||
|
||||
## Teknoloji Stack
|
||||
|
||||
- **Electron 28** - Desktop framework
|
||||
- **React 18** - UI library
|
||||
- **TypeScript** - Type safety
|
||||
- **Tailwind CSS** - Styling
|
||||
- **Framer Motion** - Animations
|
||||
- **Zustand** - State management
|
||||
- **Axios** - HTTP client
|
||||
- **electron-store** - Settings persistence
|
||||
|
||||
## Konfigürasyon
|
||||
|
||||
### Telegram Bildirimleri
|
||||
1. [@BotFather](https://t.me/BotFather) ile yeni bot oluşturun
|
||||
2. Bot token'ı alın
|
||||
3. Chat ID'nizi [@userinfobot](https://t.me/userinfobot) ile öğrenin
|
||||
4. Ayarlar sayfasından bilgileri girin
|
||||
|
||||
### Email Bildirimleri
|
||||
1. SMTP sunucu bilgilerini girin (Gmail, Outlook, vb.)
|
||||
2. Gmail için App Password kullanmanız gerekebilir
|
||||
3. Test butonuyla bağlantıyı kontrol edin
|
||||
|
||||
### Panel API
|
||||
1. Panel URL'sini girin
|
||||
2. Monitor ID tanımlayın
|
||||
3. Auth token'ı girin (panel'den alınır)
|
||||
|
||||
## Lisans
|
||||
|
||||
MIT
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.ipmonitor.app",
|
||||
"productName": "IP Monitor",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "assets"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"index.html"
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "dist/main/main/index.js"
|
||||
},
|
||||
"asar": true,
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-Portable-${version}.${ext}",
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "${productName}-Portable-${version}.${ext}"
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob: data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: http:; media-src 'self' blob: data: https: http: *; connect-src 'self' https: http: ws: wss: *">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>IP Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/renderer/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"name": "ip-monitor",
|
||||
"version": "1.0.0",
|
||||
"description": "Modern IP Monitor Desktop Application",
|
||||
"main": "dist/main/main/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
||||
"dev:vite": "vite",
|
||||
"dev:electron": "wait-on tcp:5173 && cross-env NODE_ENV=development electron .",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"build:electron": "npm run build && electron-builder --win",
|
||||
"build:portable": "npm run build && electron-builder --win portable",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
"ip-monitor",
|
||||
"m3u8",
|
||||
"streaming"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node-telegram-bot-api": "^0.64.13",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.6",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"electron-store": "^8.1.0",
|
||||
"framer-motion": "^10.16.16",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.294.0",
|
||||
"m3u8-parser": "^7.1.0",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"nodemailer": "^6.9.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-player": "^3.4.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"tcp-ping": "^0.1.1",
|
||||
"zustand": "^4.4.7"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
getStore,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
getChannels,
|
||||
saveChannels,
|
||||
getErrorLog,
|
||||
addErrorLog,
|
||||
clearErrorLog,
|
||||
getWindowBounds,
|
||||
saveWindowBounds,
|
||||
defaultSettings,
|
||||
isPortableMode
|
||||
} from './store'
|
||||
import { IpService } from './services/IpService'
|
||||
import { PingService } from './services/PingService'
|
||||
import { StreamChecker } from './services/StreamChecker'
|
||||
import { NotificationService } from './services/NotificationService'
|
||||
import { PanelService } from './services/PanelService'
|
||||
import { M3u8Parser } from './services/M3u8Parser'
|
||||
import { IPC_CHANNELS } from '../shared/types'
|
||||
import type { AppSettings, Channel, ErrorLogEntry } from '../shared/types'
|
||||
|
||||
// App quitting flag
|
||||
let isAppQuitting = false
|
||||
|
||||
// Services
|
||||
let ipService: IpService
|
||||
let pingService: PingService
|
||||
let streamChecker: StreamChecker
|
||||
let notificationService: NotificationService
|
||||
let panelService: PanelService
|
||||
let m3u8Parser: M3u8Parser
|
||||
|
||||
// Window and tray
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
|
||||
function createWindow() {
|
||||
const bounds = getWindowBounds()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
minWidth: 1200,
|
||||
minHeight: 700,
|
||||
frame: false,
|
||||
backgroundColor: '#0f0f12',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
icon: path.join(__dirname, '../../../assets/icon.png'),
|
||||
})
|
||||
|
||||
// Save window bounds on resize/move
|
||||
mainWindow.on('resize', () => {
|
||||
if (mainWindow && !mainWindow.isMaximized()) {
|
||||
const bounds = mainWindow.getBounds()
|
||||
saveWindowBounds(bounds)
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('move', () => {
|
||||
if (mainWindow && !mainWindow.isMaximized()) {
|
||||
const bounds = mainWindow.getBounds()
|
||||
saveWindowBounds(bounds)
|
||||
}
|
||||
})
|
||||
|
||||
// Load the app
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:5173')
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
const settings = getSettings()
|
||||
if (settings.general.minimizeToTray && !isAppQuitting) {
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
// Create a simple tray icon
|
||||
const iconPath = path.join(__dirname, '../../../assets/tray-icon.png')
|
||||
let icon: Electron.NativeImage
|
||||
|
||||
try {
|
||||
icon = nativeImage.createFromPath(iconPath)
|
||||
if (icon.isEmpty()) {
|
||||
icon = nativeImage.createEmpty()
|
||||
}
|
||||
} catch {
|
||||
icon = nativeImage.createEmpty()
|
||||
}
|
||||
|
||||
tray = new Tray(icon)
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Göster',
|
||||
click: () => {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'İzlemeyi Başlat',
|
||||
click: () => {
|
||||
streamChecker?.start()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'İzlemeyi Durdur',
|
||||
click: () => {
|
||||
streamChecker?.stop()
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: isPortableMode() ? 'Portable Mod ✓' : 'Normal Mod',
|
||||
enabled: false,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Çıkış',
|
||||
click: () => {
|
||||
isAppQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
tray.setToolTip('IP Monitor')
|
||||
tray.setContextMenu(contextMenu)
|
||||
|
||||
tray.on('double-click', () => {
|
||||
mainWindow?.show()
|
||||
mainWindow?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function initializeServices() {
|
||||
const settings = getSettings()
|
||||
|
||||
ipService = new IpService()
|
||||
pingService = new PingService(settings.monitoring.timeout)
|
||||
m3u8Parser = new M3u8Parser()
|
||||
notificationService = new NotificationService(settings.notifications)
|
||||
panelService = new PanelService(settings.panel)
|
||||
|
||||
streamChecker = new StreamChecker(
|
||||
settings.monitoring,
|
||||
(result) => {
|
||||
// Send result to renderer
|
||||
mainWindow?.webContents.send(IPC_CHANNELS.MONITORING_UPDATE, result)
|
||||
|
||||
// Auto notify on error
|
||||
if (settings.notifications.autoNotify && result.status === 'error') {
|
||||
const channels = getChannels()
|
||||
const channel = channels.find(c => c.id === result.channelId)
|
||||
if (channel) {
|
||||
// Add to error log
|
||||
const errorEntry: ErrorLogEntry = {
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
channelName: channel.name,
|
||||
channelId: channel.id,
|
||||
errorType: result.errorType || 'UNKNOWN',
|
||||
errorMessage: result.error || 'Bilinmeyen hata',
|
||||
httpStatus: result.httpStatus,
|
||||
notified: true,
|
||||
}
|
||||
addErrorLog(errorEntry)
|
||||
|
||||
// Send notification
|
||||
notificationService.sendNotification(
|
||||
`🔴 ${channel.name} - Yayın Hatası`,
|
||||
result.error || 'Bilinmeyen hata'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('[App] Services initialized')
|
||||
console.log('[App] Portable mode:', isPortableMode())
|
||||
}
|
||||
|
||||
function setupIpcHandlers() {
|
||||
// Window controls
|
||||
ipcMain.on('window:minimize', () => mainWindow?.minimize())
|
||||
ipcMain.on('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize()
|
||||
} else {
|
||||
mainWindow?.maximize()
|
||||
}
|
||||
})
|
||||
ipcMain.on('window:close', () => mainWindow?.close())
|
||||
|
||||
// IP Service
|
||||
ipcMain.handle(IPC_CHANNELS.GET_IP_INFO, async () => {
|
||||
return await ipService.getIpInfo()
|
||||
})
|
||||
|
||||
// Channel Management
|
||||
ipcMain.handle(IPC_CHANNELS.PARSE_M3U8, async (_, url: string) => {
|
||||
const newChannels = await m3u8Parser.parse(url)
|
||||
const existingChannels = getChannels()
|
||||
saveChannels([...existingChannels, ...newChannels])
|
||||
return newChannels
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_CHANNELS, async () => {
|
||||
return getChannels()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_CHANNEL, async (_, channel: Channel) => {
|
||||
const channels = getChannels()
|
||||
const index = channels.findIndex(c => c.id === channel.id)
|
||||
if (index !== -1) {
|
||||
channels[index] = channel
|
||||
saveChannels(channels)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SET_MONITORED, async (_, channelId: string, monitored: boolean) => {
|
||||
const channels = getChannels()
|
||||
const channel = channels.find(c => c.id === channelId)
|
||||
if (channel) {
|
||||
channel.isMonitored = monitored
|
||||
saveChannels(channels)
|
||||
}
|
||||
})
|
||||
|
||||
// Monitoring
|
||||
ipcMain.handle(IPC_CHANNELS.START_MONITORING, async () => {
|
||||
const channels = getChannels()
|
||||
const monitoredChannels = channels.filter(c => c.isMonitored)
|
||||
streamChecker.setChannels(monitoredChannels)
|
||||
streamChecker.start()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.STOP_MONITORING, async () => {
|
||||
streamChecker.stop()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_MONITORING_STATUS, async () => {
|
||||
return { isRunning: streamChecker.isRunning() }
|
||||
})
|
||||
|
||||
// Ping
|
||||
ipcMain.handle(IPC_CHANNELS.PING_HOST, async (_, host: string) => {
|
||||
return await pingService.ping(host)
|
||||
})
|
||||
|
||||
// Notifications
|
||||
ipcMain.handle(IPC_CHANNELS.SEND_NOTIFICATION, async (_, title: string, body: string, type?: string) => {
|
||||
await notificationService.sendNotification(title, body, type as 'windows' | 'telegram' | 'email')
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.TEST_NOTIFICATION, async (_, type: string) => {
|
||||
return await notificationService.testNotification(type as 'windows' | 'telegram' | 'email')
|
||||
})
|
||||
|
||||
// Panel API
|
||||
ipcMain.handle(IPC_CHANNELS.PANEL_REGISTER, async (_, config) => {
|
||||
panelService.updateConfig(config)
|
||||
return await panelService.register()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.PANEL_GET_IPS, async () => {
|
||||
return await panelService.getIpPool()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.PANEL_SEND_VOTE, async (_, vote) => {
|
||||
return await panelService.sendBurnVote(vote)
|
||||
})
|
||||
|
||||
// Settings
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SETTINGS, async () => {
|
||||
return getSettings()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SAVE_SETTINGS, async (_, settings: AppSettings) => {
|
||||
saveSettings(settings)
|
||||
// Update services with new settings
|
||||
notificationService.updateSettings(settings.notifications)
|
||||
panelService.updateConfig(settings.panel)
|
||||
streamChecker.updateConfig(settings.monitoring)
|
||||
})
|
||||
|
||||
// Error Log
|
||||
ipcMain.handle('errorlog:get', async () => {
|
||||
return getErrorLog()
|
||||
})
|
||||
|
||||
ipcMain.handle('errorlog:clear', async () => {
|
||||
clearErrorLog()
|
||||
})
|
||||
|
||||
// App Info
|
||||
ipcMain.handle('app:info', async () => {
|
||||
return {
|
||||
version: app.getVersion(),
|
||||
isPortable: isPortableMode(),
|
||||
dataPath: getStore().path,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(() => {
|
||||
// Initialize store first
|
||||
getStore()
|
||||
|
||||
initializeServices()
|
||||
createWindow()
|
||||
createTray()
|
||||
setupIpcHandlers()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isAppQuitting = true
|
||||
streamChecker?.stop()
|
||||
})
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { IPC_CHANNELS } from '../shared/types'
|
||||
import type { Channel, AppSettings, BurnVote, NotificationType } from '../shared/types'
|
||||
|
||||
// Expose protected methods to renderer
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
// Window controls
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
|
||||
// IP Service
|
||||
getIpInfo: () => ipcRenderer.invoke(IPC_CHANNELS.GET_IP_INFO),
|
||||
|
||||
// Channel Management
|
||||
parseM3u8: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.PARSE_M3U8, url),
|
||||
getChannels: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CHANNELS),
|
||||
updateChannel: (channel: Channel) => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHANNEL, channel),
|
||||
setChannelMonitored: (channelId: string, monitored: boolean) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SET_MONITORED, channelId, monitored),
|
||||
|
||||
// Monitoring
|
||||
startMonitoring: () => ipcRenderer.invoke(IPC_CHANNELS.START_MONITORING),
|
||||
stopMonitoring: () => ipcRenderer.invoke(IPC_CHANNELS.STOP_MONITORING),
|
||||
getMonitoringStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_MONITORING_STATUS),
|
||||
onMonitoringUpdate: (callback: (result: unknown) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, result: unknown) => callback(result)
|
||||
ipcRenderer.on(IPC_CHANNELS.MONITORING_UPDATE, handler)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.MONITORING_UPDATE, handler)
|
||||
},
|
||||
|
||||
// Ping
|
||||
pingHost: (host: string) => ipcRenderer.invoke(IPC_CHANNELS.PING_HOST, host),
|
||||
|
||||
// Notifications
|
||||
sendNotification: (title: string, body: string, type?: NotificationType) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SEND_NOTIFICATION, title, body, type),
|
||||
testNotification: (type: NotificationType) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFICATION, type),
|
||||
|
||||
// Panel API
|
||||
panelRegister: (config: unknown) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_REGISTER, config),
|
||||
panelGetIps: () => ipcRenderer.invoke(IPC_CHANNELS.PANEL_GET_IPS),
|
||||
panelSendVote: (vote: BurnVote) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_SEND_VOTE, vote),
|
||||
|
||||
// Settings
|
||||
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS),
|
||||
saveSettings: (settings: AppSettings) => ipcRenderer.invoke(IPC_CHANNELS.SAVE_SETTINGS, settings),
|
||||
|
||||
// Error Log
|
||||
getErrorLog: () => ipcRenderer.invoke('errorlog:get'),
|
||||
clearErrorLog: () => ipcRenderer.invoke('errorlog:clear'),
|
||||
|
||||
// App Info
|
||||
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||
})
|
||||
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import * as nodemailer from 'nodemailer'
|
||||
|
||||
export interface EmailConfig {
|
||||
smtpHost: string
|
||||
smtpPort: number
|
||||
smtpUser: string
|
||||
smtpPass: string
|
||||
fromEmail: string
|
||||
toEmail: string
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
private config: EmailConfig
|
||||
private transporter: nodemailer.Transporter | null = null
|
||||
|
||||
constructor(config: EmailConfig) {
|
||||
this.config = config
|
||||
this.initTransporter()
|
||||
}
|
||||
|
||||
updateConfig(config: EmailConfig): void {
|
||||
this.config = config
|
||||
this.initTransporter()
|
||||
}
|
||||
|
||||
private initTransporter(): void {
|
||||
if (!this.config.smtpHost || !this.config.smtpUser) {
|
||||
this.transporter = null
|
||||
return
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.config.smtpHost,
|
||||
port: this.config.smtpPort,
|
||||
secure: this.config.smtpPort === 465,
|
||||
auth: {
|
||||
user: this.config.smtpUser,
|
||||
pass: this.config.smtpPass,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async sendEmail(subject: string, text: string, html?: string): Promise<boolean> {
|
||||
if (!this.transporter) {
|
||||
console.warn('Email transporter not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transporter.sendMail({
|
||||
from: this.config.fromEmail || this.config.smtpUser,
|
||||
to: this.config.toEmail,
|
||||
subject: `[IP Monitor] ${subject}`,
|
||||
text,
|
||||
html: html || this.generateHtml(subject, text),
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async sendAlert(title: string, message: string): Promise<boolean> {
|
||||
return this.sendEmail(title, message)
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.transporter) {
|
||||
return { success: false, error: 'Email not configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transporter.verify()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
private generateHtml(title: string, content: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #0f0f12;
|
||||
color: #e4e4e7;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #18181b;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
border: 1px solid #27272a;
|
||||
}
|
||||
h1 {
|
||||
color: #e06c75;
|
||||
border-bottom: 2px solid #e06c75;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
background: #1e1e22;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #27272a;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
color: #71717a;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${title}</h1>
|
||||
<div class="content">
|
||||
<pre>${content}</pre>
|
||||
</div>
|
||||
<p class="footer">
|
||||
IP Monitor - ${new Date().toLocaleString('tr-TR')}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import axios from 'axios'
|
||||
import type { IpInfo } from '../../shared/types'
|
||||
|
||||
export class IpService {
|
||||
private cache: IpInfo | null = null
|
||||
private cacheTime: number = 0
|
||||
private cacheDuration = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
async getIpInfo(): Promise<IpInfo> {
|
||||
// Return cached data if still valid
|
||||
if (this.cache && Date.now() - this.cacheTime < this.cacheDuration) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
// Try ip-api.com first (more detailed info, free)
|
||||
const response = await axios.get('http://ip-api.com/json/', {
|
||||
timeout: 10000,
|
||||
params: {
|
||||
fields: 'status,message,country,countryCode,city,isp,query,timezone'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.status === 'success') {
|
||||
const info: IpInfo = {
|
||||
ip: response.data.query,
|
||||
country: response.data.country,
|
||||
countryCode: response.data.countryCode,
|
||||
city: response.data.city,
|
||||
isp: response.data.isp,
|
||||
timezone: response.data.timezone
|
||||
}
|
||||
|
||||
this.cache = info
|
||||
this.cacheTime = Date.now()
|
||||
return info
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ip-api.com failed:', error)
|
||||
}
|
||||
|
||||
// Fallback to ipify + ipapi
|
||||
try {
|
||||
const ipResponse = await axios.get('https://api.ipify.org?format=json', {
|
||||
timeout: 10000
|
||||
})
|
||||
const ip = ipResponse.data.ip
|
||||
|
||||
const geoResponse = await axios.get(`https://ipapi.co/${ip}/json/`, {
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const info: IpInfo = {
|
||||
ip: ip,
|
||||
country: geoResponse.data.country_name || 'Unknown',
|
||||
countryCode: geoResponse.data.country_code || 'XX',
|
||||
city: geoResponse.data.city,
|
||||
isp: geoResponse.data.org,
|
||||
timezone: geoResponse.data.timezone
|
||||
}
|
||||
|
||||
this.cache = info
|
||||
this.cacheTime = Date.now()
|
||||
return info
|
||||
} catch (error) {
|
||||
console.error('Fallback IP lookup failed:', error)
|
||||
throw new Error('Failed to get IP information')
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache = null
|
||||
this.cacheTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import axios from 'axios'
|
||||
import type { Channel } from '../../shared/types'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
interface M3u8Entry {
|
||||
name: string
|
||||
url: string
|
||||
group?: string
|
||||
logo?: string
|
||||
tvgId?: string
|
||||
tvgName?: string
|
||||
}
|
||||
|
||||
export class M3u8Parser {
|
||||
/**
|
||||
* Parse M3U8/M3U playlist from URL
|
||||
*/
|
||||
async parse(url: string): Promise<Channel[]> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
|
||||
const content = response.data as string
|
||||
return this.parseContent(content)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch M3U8:', error)
|
||||
throw new Error(`Failed to fetch playlist: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U8/M3U content string
|
||||
*/
|
||||
parseContent(content: string): Channel[] {
|
||||
const lines = content.split('\n').map(line => line.trim())
|
||||
const entries: M3u8Entry[] = []
|
||||
|
||||
let currentEntry: Partial<M3u8Entry> | null = null
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Skip empty lines and M3U header
|
||||
if (!line || line === '#EXTM3U') continue
|
||||
|
||||
// Parse EXTINF line
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
currentEntry = this.parseExtInf(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse additional attributes
|
||||
if (line.startsWith('#EXTGRP:') && currentEntry) {
|
||||
currentEntry.group = line.replace('#EXTGRP:', '').trim()
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other comments
|
||||
if (line.startsWith('#')) continue
|
||||
|
||||
// This should be a URL
|
||||
if (currentEntry && this.isValidUrl(line)) {
|
||||
currentEntry.url = line
|
||||
entries.push(currentEntry as M3u8Entry)
|
||||
currentEntry = null
|
||||
} else if (this.isValidUrl(line)) {
|
||||
// URL without EXTINF
|
||||
entries.push({
|
||||
name: this.extractNameFromUrl(line),
|
||||
url: line
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map(entry => this.entryToChannel(entry))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse #EXTINF line and extract metadata
|
||||
*/
|
||||
private parseExtInf(line: string): Partial<M3u8Entry> {
|
||||
const entry: Partial<M3u8Entry> = {}
|
||||
|
||||
// Remove #EXTINF: prefix
|
||||
let content = line.replace('#EXTINF:', '')
|
||||
|
||||
// Extract duration (usually -1 for live streams)
|
||||
const durationMatch = content.match(/^-?\d+/)
|
||||
if (durationMatch) {
|
||||
content = content.substring(durationMatch[0].length)
|
||||
}
|
||||
|
||||
// Parse attributes in format: key="value"
|
||||
const attrRegex = /(\w+(?:-\w+)*)=["']([^"']*)["']/g
|
||||
let match
|
||||
|
||||
while ((match = attrRegex.exec(content)) !== null) {
|
||||
const [, key, value] = match
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'tvg-id':
|
||||
entry.tvgId = value
|
||||
break
|
||||
case 'tvg-name':
|
||||
entry.tvgName = value
|
||||
break
|
||||
case 'tvg-logo':
|
||||
case 'logo':
|
||||
entry.logo = value
|
||||
break
|
||||
case 'group-title':
|
||||
entry.group = value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract channel name (after last comma)
|
||||
const nameMatch = content.match(/,(.+)$/)
|
||||
if (nameMatch) {
|
||||
entry.name = nameMatch[1].trim()
|
||||
} else {
|
||||
// Try to use tvg-name if no name after comma
|
||||
entry.name = entry.tvgName || 'Unknown Channel'
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert M3U entry to Channel object
|
||||
*/
|
||||
private entryToChannel(entry: M3u8Entry): Channel {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
name: entry.name,
|
||||
url: entry.url,
|
||||
group: entry.group,
|
||||
logo: entry.logo,
|
||||
isMonitored: false,
|
||||
status: 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is a valid URL
|
||||
*/
|
||||
private isValidUrl(str: string): boolean {
|
||||
try {
|
||||
new URL(str)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract channel name from URL
|
||||
*/
|
||||
private extractNameFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const pathParts = parsed.pathname.split('/')
|
||||
const lastPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2]
|
||||
|
||||
// Remove file extension
|
||||
const name = lastPart.replace(/\.(m3u8?|ts|mp4)$/i, '')
|
||||
|
||||
// Clean up the name
|
||||
return name
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || parsed.hostname
|
||||
} catch {
|
||||
return 'Unknown Channel'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique groups from channels
|
||||
*/
|
||||
static getGroups(channels: Channel[]): string[] {
|
||||
const groups = new Set<string>()
|
||||
channels.forEach(channel => {
|
||||
if (channel.group) {
|
||||
groups.add(channel.group)
|
||||
}
|
||||
})
|
||||
return Array.from(groups).sort()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import { Notification, app } from 'electron'
|
||||
import axios from 'axios'
|
||||
import * as nodemailer from 'nodemailer'
|
||||
import type { NotificationSettings, NotificationType } from '../../shared/types'
|
||||
|
||||
export class NotificationService {
|
||||
private settings: NotificationSettings
|
||||
private emailTransporter: nodemailer.Transporter | null = null
|
||||
|
||||
constructor(settings: NotificationSettings) {
|
||||
this.settings = settings
|
||||
this.initializeEmailTransporter()
|
||||
}
|
||||
|
||||
updateSettings(settings: NotificationSettings): void {
|
||||
this.settings = settings
|
||||
this.initializeEmailTransporter()
|
||||
}
|
||||
|
||||
private initializeEmailTransporter(): void {
|
||||
if (this.settings.email.enabled && this.settings.email.smtpHost) {
|
||||
this.emailTransporter = nodemailer.createTransport({
|
||||
host: this.settings.email.smtpHost,
|
||||
port: this.settings.email.smtpPort,
|
||||
secure: this.settings.email.smtpPort === 465,
|
||||
auth: {
|
||||
user: this.settings.email.smtpUser,
|
||||
pass: this.settings.email.smtpPass,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification through all enabled channels
|
||||
*/
|
||||
async sendNotification(title: string, body: string, specificType?: NotificationType): Promise<void> {
|
||||
if (!this.settings.enabled) return
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
if (specificType) {
|
||||
// Send to specific channel
|
||||
switch (specificType) {
|
||||
case 'windows':
|
||||
if (this.settings.windows.enabled) {
|
||||
promises.push(this.sendWindowsNotification(title, body))
|
||||
}
|
||||
break
|
||||
case 'telegram':
|
||||
if (this.settings.telegram.enabled) {
|
||||
promises.push(this.sendTelegramNotification(title, body))
|
||||
}
|
||||
break
|
||||
case 'email':
|
||||
if (this.settings.email.enabled) {
|
||||
promises.push(this.sendEmailNotification(title, body))
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Send to all enabled channels
|
||||
if (this.settings.windows.enabled) {
|
||||
promises.push(this.sendWindowsNotification(title, body))
|
||||
}
|
||||
if (this.settings.telegram.enabled) {
|
||||
promises.push(this.sendTelegramNotification(title, body))
|
||||
}
|
||||
if (this.settings.email.enabled) {
|
||||
promises.push(this.sendEmailNotification(title, body))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Windows toast notification
|
||||
*/
|
||||
async sendWindowsNotification(title: string, body: string): Promise<void> {
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn('Notifications not supported on this system')
|
||||
return
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
icon: undefined, // Will use app icon
|
||||
silent: false,
|
||||
})
|
||||
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Telegram notification
|
||||
*/
|
||||
async sendTelegramNotification(title: string, body: string): Promise<void> {
|
||||
const { botToken, chatId } = this.settings.telegram
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
throw new Error('Telegram bot token or chat ID not configured')
|
||||
}
|
||||
|
||||
const message = `*${this.escapeMarkdown(title)}*\n\n${this.escapeMarkdown(body)}`
|
||||
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`
|
||||
|
||||
await axios.post(url, {
|
||||
chat_id: chatId,
|
||||
text: message,
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: true,
|
||||
}, {
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification
|
||||
*/
|
||||
async sendEmailNotification(title: string, body: string): Promise<void> {
|
||||
if (!this.emailTransporter) {
|
||||
throw new Error('Email transporter not configured')
|
||||
}
|
||||
|
||||
const { fromEmail, toEmail } = this.settings.email
|
||||
|
||||
if (!fromEmail || !toEmail) {
|
||||
throw new Error('Email addresses not configured')
|
||||
}
|
||||
|
||||
await this.emailTransporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: toEmail,
|
||||
subject: `[IP Monitor] ${title}`,
|
||||
text: body,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #e06c75; border-bottom: 2px solid #e06c75; padding-bottom: 10px;">
|
||||
${title}
|
||||
</h2>
|
||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin-top: 15px;">
|
||||
<pre style="white-space: pre-wrap; font-family: 'Courier New', monospace;">${body}</pre>
|
||||
</div>
|
||||
<p style="color: #888; font-size: 12px; margin-top: 20px;">
|
||||
Sent by IP Monitor at ${new Date().toLocaleString('tr-TR')}
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test notification for a specific channel
|
||||
*/
|
||||
async testNotification(type: NotificationType): Promise<{ success: boolean; error?: string }> {
|
||||
const title = 'Test Bildirimi'
|
||||
const body = `Bu bir test bildirimidir.\nZaman: ${new Date().toLocaleString('tr-TR')}`
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'windows':
|
||||
await this.sendWindowsNotification(title, body)
|
||||
break
|
||||
case 'telegram':
|
||||
await this.sendTelegramNotification(title, body)
|
||||
break
|
||||
case 'email':
|
||||
await this.sendEmailNotification(title, body)
|
||||
break
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters for Telegram Markdown
|
||||
*/
|
||||
private escapeMarkdown(text: string): string {
|
||||
return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for notification
|
||||
*/
|
||||
static formatErrorNotification(
|
||||
channelName: string,
|
||||
errorType: string,
|
||||
errorMessage: string,
|
||||
httpStatus?: number
|
||||
): { title: string; body: string } {
|
||||
let statusEmoji = '🔴'
|
||||
if (errorType === 'TIMEOUT') statusEmoji = '⏱️'
|
||||
if (errorType === 'CONNRESET') statusEmoji = '🔌'
|
||||
if (httpStatus === 404) statusEmoji = '❌'
|
||||
|
||||
const title = `${statusEmoji} ${channelName} - Yayın Hatası`
|
||||
|
||||
let body = `Hata Tipi: ${errorType}\n`
|
||||
if (httpStatus) body += `HTTP Status: ${httpStatus}\n`
|
||||
body += `Detay: ${errorMessage}\n`
|
||||
body += `Zaman: ${new Date().toLocaleString('tr-TR')}`
|
||||
|
||||
return { title, body }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { PanelConfig, PanelIpPool, BurnVote } from '../../shared/types'
|
||||
|
||||
export class PanelService {
|
||||
private config: PanelConfig
|
||||
private client: AxiosInstance | null = null
|
||||
|
||||
constructor(config: PanelConfig) {
|
||||
this.config = config
|
||||
this.initializeClient()
|
||||
}
|
||||
|
||||
updateConfig(config: PanelConfig): void {
|
||||
this.config = config
|
||||
this.initializeClient()
|
||||
}
|
||||
|
||||
private initializeClient(): void {
|
||||
if (!this.config.apiUrl) {
|
||||
this.client = null
|
||||
return
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.config.apiUrl,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.config.authToken && {
|
||||
'Authorization': `Bearer ${this.config.authToken}`
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register monitor with panel
|
||||
* Based on MONITOR_API_GUIDE.md
|
||||
*/
|
||||
async register(): Promise<{ success: boolean; authToken?: string; error?: string }> {
|
||||
if (!this.client) {
|
||||
return { success: false, error: 'Panel not configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.post('/api.php?endpoint=servers', {
|
||||
server_id: this.config.monitorId,
|
||||
server_type: 'monitor',
|
||||
hostname: require('os').hostname(),
|
||||
ip_address: await this.getLocalIp(),
|
||||
country_code: 'TR' // Could be detected dynamically
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
authToken: response.data.auth_token
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: response.data.message }
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IP pool from panel
|
||||
* Based on MONITOR_API_GUIDE.md - Option A: REST API (Polling)
|
||||
*/
|
||||
async getIpPool(): Promise<PanelIpPool> {
|
||||
if (!this.client || !this.config.enabled) {
|
||||
return { active: [], honeypot: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/api.php', {
|
||||
params: {
|
||||
endpoint: 'ip-pool',
|
||||
for_monitor: true
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.ips || { active: [], honeypot: [] }
|
||||
}
|
||||
|
||||
console.error('Failed to get IP pool:', response.data.message)
|
||||
return { active: [], honeypot: [] }
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch IP pool:', error)
|
||||
return { active: [], honeypot: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send burn vote
|
||||
* Based on MONITOR_API_GUIDE.md - Section 2: Burn Bildirimi
|
||||
*/
|
||||
async sendBurnVote(vote: BurnVote): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.client || !this.config.enabled) {
|
||||
return { success: false, error: 'Panel not enabled' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.post('/api.php?endpoint=burn-vote', {
|
||||
ip_address: vote.ip_address,
|
||||
vote: vote.vote,
|
||||
reason: vote.reason,
|
||||
evidence: {
|
||||
...vote.evidence,
|
||||
monitor_id: this.config.monitorId,
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('Burn vote submitted:', {
|
||||
ip: vote.ip_address,
|
||||
vote: vote.vote,
|
||||
current_votes: response.data.current_votes
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return { success: false, error: response.data.message }
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report IP health status
|
||||
*/
|
||||
async reportHealth(
|
||||
ipAddress: string,
|
||||
isWorking: boolean,
|
||||
checkResult: {
|
||||
checkType: string
|
||||
port?: number
|
||||
latencyMs?: number
|
||||
error?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const vote: BurnVote = {
|
||||
ip_address: ipAddress,
|
||||
vote: isWorking ? 'ok' : 'burn',
|
||||
reason: isWorking ? 'IP working normally' : checkResult.error || 'Check failed',
|
||||
evidence: {
|
||||
check_type: checkResult.checkType,
|
||||
port: checkResult.port,
|
||||
response_time_ms: checkResult.latencyMs,
|
||||
error_message: checkResult.error,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
monitor_id: this.config.monitorId
|
||||
}
|
||||
}
|
||||
|
||||
await this.sendBurnVote(vote)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local IP address
|
||||
*/
|
||||
private async getLocalIp(): Promise<string> {
|
||||
const os = require('os')
|
||||
const interfaces = os.networkInterfaces()
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name] || []) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if panel is configured and enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && !!this.config.apiUrl && !!this.config.authToken
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import * as net from 'net'
|
||||
import * as dns from 'dns'
|
||||
import { promisify } from 'util'
|
||||
import type { PingResult } from '../../shared/types'
|
||||
|
||||
const dnsLookup = promisify(dns.lookup)
|
||||
|
||||
export class PingService {
|
||||
private timeout: number
|
||||
|
||||
constructor(timeout: number = 5000) {
|
||||
this.timeout = timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping a host using TCP connection (works without admin privileges)
|
||||
*/
|
||||
async ping(host: string, port: number = 80): Promise<PingResult> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Resolve hostname first
|
||||
let ip = host
|
||||
if (!this.isIpAddress(host)) {
|
||||
try {
|
||||
const result = await dnsLookup(host)
|
||||
ip = result.address
|
||||
} catch (dnsError) {
|
||||
return {
|
||||
host,
|
||||
alive: false,
|
||||
error: `DNS lookup failed: ${(dnsError as Error).message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TCP connection test
|
||||
const alive = await this.tcpPing(ip, port)
|
||||
const time = Date.now() - startTime
|
||||
|
||||
return {
|
||||
host,
|
||||
alive,
|
||||
time: alive ? time : undefined,
|
||||
error: alive ? undefined : 'Connection failed'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
host,
|
||||
alive: false,
|
||||
error: (error as Error).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tcpPing(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket()
|
||||
|
||||
socket.setTimeout(this.timeout)
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
socket.on('error', () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
socket.connect(port, host)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping multiple hosts in parallel
|
||||
*/
|
||||
async pingMultiple(hosts: string[], port: number = 80): Promise<PingResult[]> {
|
||||
const results = await Promise.all(
|
||||
hosts.map(host => this.ping(host, port))
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is an IP address
|
||||
*/
|
||||
private isIpAddress(str: string): boolean {
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return ipv4Regex.test(str) || ipv6Regex.test(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract host from URL
|
||||
*/
|
||||
static extractHost(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeout(timeout: number): void {
|
||||
this.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import axios, { AxiosError } from 'axios'
|
||||
import type { Channel, MonitoringResult, ChannelStatus } from '../../shared/types'
|
||||
|
||||
interface MonitoringConfig {
|
||||
pingInterval: number
|
||||
streamCheckInterval: number
|
||||
timeout: number
|
||||
retryCount: number
|
||||
}
|
||||
|
||||
type MonitoringCallback = (result: MonitoringResult) => void
|
||||
|
||||
export class StreamChecker {
|
||||
private config: MonitoringConfig
|
||||
private channels: Channel[] = []
|
||||
private callback: MonitoringCallback
|
||||
private intervalId: NodeJS.Timeout | null = null
|
||||
private running = false
|
||||
|
||||
constructor(config: MonitoringConfig, callback: MonitoringCallback) {
|
||||
this.config = config
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
setChannels(channels: Channel[]): void {
|
||||
this.channels = channels
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return
|
||||
|
||||
this.running = true
|
||||
this.checkAllChannels()
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkAllChannels()
|
||||
}, this.config.streamCheckInterval * 1000)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
updateConfig(config: MonitoringConfig): void {
|
||||
this.config = config
|
||||
|
||||
// Restart if running with new interval
|
||||
if (this.running) {
|
||||
this.stop()
|
||||
this.start()
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAllChannels(): Promise<void> {
|
||||
for (const channel of this.channels) {
|
||||
if (!this.running) break
|
||||
|
||||
const result = await this.checkChannel(channel)
|
||||
this.callback(result)
|
||||
|
||||
// Small delay between checks to prevent overwhelming
|
||||
await this.delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
async checkChannel(channel: Channel): Promise<MonitoringResult> {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Notify checking status
|
||||
this.callback({
|
||||
channelId: channel.id,
|
||||
timestamp: startTime,
|
||||
status: 'checking'
|
||||
})
|
||||
|
||||
let lastError: string | undefined
|
||||
let errorType: MonitoringResult['errorType']
|
||||
let httpStatus: number | undefined
|
||||
|
||||
for (let attempt = 0; attempt < this.config.retryCount; attempt++) {
|
||||
try {
|
||||
const response = await axios.head(channel.url, {
|
||||
timeout: this.config.timeout,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
maxRedirects: 5,
|
||||
validateStatus: () => true // Accept any status
|
||||
})
|
||||
|
||||
httpStatus = response.status
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// Check for successful response
|
||||
if (response.status >= 200 && response.status < 400) {
|
||||
return {
|
||||
channelId: channel.id,
|
||||
timestamp: Date.now(),
|
||||
status: 'online',
|
||||
latency,
|
||||
httpStatus
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP error status
|
||||
lastError = `HTTP ${response.status} ${response.statusText}`
|
||||
errorType = 'HTTP_ERROR'
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
channelId: channel.id,
|
||||
timestamp: Date.now(),
|
||||
status: 'offline',
|
||||
httpStatus,
|
||||
error: lastError,
|
||||
errorType
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
|
||||
if (axiosError.code === 'ECONNRESET') {
|
||||
lastError = 'ERR_CONNECTION_RESET'
|
||||
errorType = 'CONNRESET'
|
||||
} else if (axiosError.code === 'ECONNREFUSED') {
|
||||
lastError = 'ERR_CONNECTION_REFUSED'
|
||||
errorType = 'CONNREFUSED'
|
||||
} else if (axiosError.code === 'ETIMEDOUT' || axiosError.code === 'ECONNABORTED') {
|
||||
lastError = 'Connection timeout'
|
||||
errorType = 'TIMEOUT'
|
||||
} else if (axiosError.code === 'ENOTFOUND') {
|
||||
lastError = 'DNS lookup failed'
|
||||
errorType = 'UNKNOWN'
|
||||
} else {
|
||||
lastError = axiosError.message
|
||||
errorType = 'UNKNOWN'
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before retry
|
||||
if (attempt < this.config.retryCount - 1) {
|
||||
await this.delay(1000 * (attempt + 1)) // Exponential backoff
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
return {
|
||||
channelId: channel.id,
|
||||
timestamp: Date.now(),
|
||||
status: 'error',
|
||||
httpStatus,
|
||||
error: lastError,
|
||||
errorType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if M3U8 playlist is valid and can be parsed
|
||||
*/
|
||||
async checkM3u8Content(url: string): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
timeout: this.config.timeout,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
},
|
||||
maxRedirects: 5
|
||||
})
|
||||
|
||||
const content = response.data as string
|
||||
|
||||
// Check for M3U8 header
|
||||
if (!content.includes('#EXTM3U')) {
|
||||
return { valid: false, error: 'Not a valid M3U8 file' }
|
||||
}
|
||||
|
||||
// Check for segments or variants
|
||||
const hasContent = content.includes('#EXTINF') || content.includes('#EXT-X-STREAM-INF')
|
||||
|
||||
if (!hasContent) {
|
||||
return { valid: false, error: 'M3U8 file has no content' }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
return { valid: false, error: axiosError.message }
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import axios from 'axios'
|
||||
|
||||
export interface TelegramConfig {
|
||||
botToken: string
|
||||
chatId: string
|
||||
}
|
||||
|
||||
export class TelegramBot {
|
||||
private config: TelegramConfig
|
||||
private baseUrl: string
|
||||
|
||||
constructor(config: TelegramConfig) {
|
||||
this.config = config
|
||||
this.baseUrl = `https://api.telegram.org/bot${config.botToken}`
|
||||
}
|
||||
|
||||
updateConfig(config: TelegramConfig): void {
|
||||
this.config = config
|
||||
this.baseUrl = `https://api.telegram.org/bot${config.botToken}`
|
||||
}
|
||||
|
||||
async sendMessage(text: string, parseMode: 'Markdown' | 'HTML' = 'Markdown'): Promise<boolean> {
|
||||
if (!this.config.botToken || !this.config.chatId) {
|
||||
console.warn('Telegram bot not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(`${this.baseUrl}/sendMessage`, {
|
||||
chat_id: this.config.chatId,
|
||||
text,
|
||||
parse_mode: parseMode,
|
||||
disable_web_page_preview: true
|
||||
}, {
|
||||
timeout: 10000
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send Telegram message:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async sendAlert(title: string, message: string): Promise<boolean> {
|
||||
const formattedMessage = `*${this.escapeMarkdown(title)}*\n\n${this.escapeMarkdown(message)}`
|
||||
return this.sendMessage(formattedMessage)
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.config.botToken) {
|
||||
return { success: false, error: 'Bot token not configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${this.baseUrl}/getMe`, { timeout: 10000 })
|
||||
if (response.data.ok) {
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: 'Invalid bot token' }
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
private escapeMarkdown(text: string): string {
|
||||
return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import Store from 'electron-store'
|
||||
import { app } from 'electron'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import type { AppSettings, Channel, ErrorLogEntry } from '../shared/types'
|
||||
|
||||
// Default settings
|
||||
export const defaultSettings: AppSettings = {
|
||||
general: {
|
||||
startMinimized: false,
|
||||
minimizeToTray: true,
|
||||
autoStart: false,
|
||||
language: 'tr',
|
||||
},
|
||||
monitoring: {
|
||||
pingInterval: 30,
|
||||
streamCheckInterval: 60,
|
||||
timeout: 5000,
|
||||
retryCount: 2,
|
||||
},
|
||||
notifications: {
|
||||
enabled: true,
|
||||
windows: { enabled: true },
|
||||
telegram: {
|
||||
enabled: false,
|
||||
botToken: '',
|
||||
chatId: '',
|
||||
},
|
||||
email: {
|
||||
enabled: false,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpUser: '',
|
||||
smtpPass: '',
|
||||
fromEmail: '',
|
||||
toEmail: '',
|
||||
},
|
||||
autoNotify: true,
|
||||
notifyOnPingFail: true,
|
||||
notifyOnStreamError: true,
|
||||
notifyOn404: true,
|
||||
notifyOnConnReset: true,
|
||||
},
|
||||
panel: {
|
||||
apiUrl: '',
|
||||
authToken: '',
|
||||
monitorId: '',
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Check if running in portable mode
|
||||
function isPortableMode(): boolean {
|
||||
// Check for portable marker file next to exe
|
||||
const exePath = app.getPath('exe')
|
||||
const exeDir = path.dirname(exePath)
|
||||
const portableMarker = path.join(exeDir, 'portable.txt')
|
||||
const portableDataDir = path.join(exeDir, 'data')
|
||||
|
||||
// Portable mode if:
|
||||
// 1. portable.txt exists next to exe
|
||||
// 2. OR data folder exists next to exe
|
||||
// 3. OR running from a removable drive (simplified check)
|
||||
if (fs.existsSync(portableMarker) || fs.existsSync(portableDataDir)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if exe name contains "Portable"
|
||||
if (exePath.toLowerCase().includes('portable')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the data directory for storing config
|
||||
function getDataDirectory(): string {
|
||||
if (isPortableMode()) {
|
||||
const exeDir = path.dirname(app.getPath('exe'))
|
||||
const dataDir = path.join(exeDir, 'data')
|
||||
|
||||
// Create data directory if it doesn't exist
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
|
||||
return dataDir
|
||||
}
|
||||
|
||||
// Use default app data location
|
||||
return app.getPath('userData')
|
||||
}
|
||||
|
||||
// Store schema
|
||||
interface StoreSchema {
|
||||
settings: AppSettings
|
||||
channels: Channel[]
|
||||
errorLog: ErrorLogEntry[]
|
||||
windowBounds: {
|
||||
x?: number
|
||||
y?: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
// Create store instance
|
||||
let storeInstance: Store<StoreSchema> | null = null
|
||||
|
||||
export function getStore(): Store<StoreSchema> {
|
||||
if (!storeInstance) {
|
||||
const dataDir = getDataDirectory()
|
||||
|
||||
storeInstance = new Store<StoreSchema>({
|
||||
name: 'ip-monitor-config',
|
||||
cwd: dataDir,
|
||||
defaults: {
|
||||
settings: defaultSettings,
|
||||
channels: [],
|
||||
errorLog: [],
|
||||
windowBounds: {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
},
|
||||
},
|
||||
// Enable encryption for sensitive data
|
||||
encryptionKey: 'ip-monitor-v1-key',
|
||||
// Clear invalid config
|
||||
clearInvalidConfig: true,
|
||||
})
|
||||
|
||||
console.log(`[Store] Config location: ${dataDir}`)
|
||||
console.log(`[Store] Portable mode: ${isPortableMode()}`)
|
||||
}
|
||||
|
||||
return storeInstance
|
||||
}
|
||||
|
||||
// Helper functions for common operations
|
||||
export function getSettings(): AppSettings {
|
||||
return getStore().get('settings', defaultSettings)
|
||||
}
|
||||
|
||||
export function saveSettings(settings: AppSettings): void {
|
||||
getStore().set('settings', settings)
|
||||
}
|
||||
|
||||
export function getChannels(): Channel[] {
|
||||
return getStore().get('channels', [])
|
||||
}
|
||||
|
||||
export function saveChannels(channels: Channel[]): void {
|
||||
getStore().set('channels', channels)
|
||||
}
|
||||
|
||||
export function addChannel(channel: Channel): void {
|
||||
const channels = getChannels()
|
||||
channels.push(channel)
|
||||
saveChannels(channels)
|
||||
}
|
||||
|
||||
export function updateChannel(channelId: string, updates: Partial<Channel>): void {
|
||||
const channels = getChannels()
|
||||
const index = channels.findIndex(c => c.id === channelId)
|
||||
if (index !== -1) {
|
||||
channels[index] = { ...channels[index], ...updates }
|
||||
saveChannels(channels)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeChannel(channelId: string): void {
|
||||
const channels = getChannels().filter(c => c.id !== channelId)
|
||||
saveChannels(channels)
|
||||
}
|
||||
|
||||
export function getErrorLog(): ErrorLogEntry[] {
|
||||
return getStore().get('errorLog', [])
|
||||
}
|
||||
|
||||
export function addErrorLog(entry: ErrorLogEntry): void {
|
||||
const log = getErrorLog()
|
||||
log.unshift(entry) // Add to beginning
|
||||
// Keep only last 100 entries
|
||||
if (log.length > 100) {
|
||||
log.length = 100
|
||||
}
|
||||
getStore().set('errorLog', log)
|
||||
}
|
||||
|
||||
export function clearErrorLog(): void {
|
||||
getStore().set('errorLog', [])
|
||||
}
|
||||
|
||||
export function getWindowBounds() {
|
||||
return getStore().get('windowBounds')
|
||||
}
|
||||
|
||||
export function saveWindowBounds(bounds: { x?: number; y?: number; width: number; height: number }): void {
|
||||
getStore().set('windowBounds', bounds)
|
||||
}
|
||||
|
||||
// Export portable mode check
|
||||
export { isPortableMode }
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { HashRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Channels from './pages/Channels'
|
||||
import Monitoring from './pages/Monitoring'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Layout>
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/monitoring" element={<Monitoring />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
</Layout>
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { ReactNode } from 'react'
|
||||
import Sidebar from './Sidebar'
|
||||
import TitleBar from './TitleBar'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-dark-900 overflow-hidden">
|
||||
<TitleBar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Tv2,
|
||||
Activity,
|
||||
Settings,
|
||||
Globe
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ path: '/channels', icon: Tv2, label: 'Kanallar' },
|
||||
{ path: '/monitoring', icon: Activity, label: 'İzleme' },
|
||||
{ path: '/settings', icon: Settings, label: 'Ayarlar' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<nav className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
||||
{/* Navigation Items */}
|
||||
<div className="flex-1 py-4 px-3 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group relative ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-accent-blue/20 to-accent-purple/10 text-accent-blue'
|
||||
: 'text-dark-300 hover:text-dark-100 hover:bg-dark-700'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="sidebar-active"
|
||||
className="absolute left-0 w-1 h-8 bg-accent-blue rounded-r-full"
|
||||
initial={false}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status Footer */}
|
||||
<div className="p-4 border-t border-dark-700">
|
||||
<div className="flex items-center gap-2 text-xs text-dark-400">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>Bağlantı durumu</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-accent-green status-online" />
|
||||
Aktif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Minus, Square, X, Radio } from 'lucide-react'
|
||||
|
||||
export default function TitleBar() {
|
||||
const handleMinimize = () => {
|
||||
window.electron?.minimize()
|
||||
}
|
||||
|
||||
const handleMaximize = () => {
|
||||
window.electron?.maximize()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
window.electron?.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-10 bg-dark-800 flex items-center justify-between px-4 titlebar-drag border-b border-dark-700">
|
||||
{/* App Logo & Title */}
|
||||
<div className="flex items-center gap-2 titlebar-no-drag">
|
||||
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-accent-blue to-accent-purple flex items-center justify-center">
|
||||
<Radio className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-display font-semibold text-sm text-dark-100">
|
||||
IP Monitor
|
||||
</span>
|
||||
<span className="text-xs text-dark-400 ml-2">v1.0.0</span>
|
||||
</div>
|
||||
|
||||
{/* Window Controls */}
|
||||
<div className="flex items-center gap-1 titlebar-no-drag">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<Minus className="w-4 h-4 text-dark-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<Square className="w-3 h-3 text-dark-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-accent-red transition-colors group"
|
||||
>
|
||||
<X className="w-4 h-4 text-dark-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,592 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize,
|
||||
Minimize,
|
||||
RefreshCw,
|
||||
X,
|
||||
Tv2,
|
||||
AlertCircle,
|
||||
Bug
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface VideoPlayerProps {
|
||||
url: string
|
||||
title?: string
|
||||
onClose?: () => void
|
||||
autoPlay?: boolean
|
||||
}
|
||||
|
||||
interface DebugInfo {
|
||||
hlsSupported: boolean
|
||||
nativeHlsSupported: boolean
|
||||
url: string
|
||||
videoState: string
|
||||
hlsState: string
|
||||
error: string | null
|
||||
bufferLength: number
|
||||
}
|
||||
|
||||
export default function VideoPlayer({
|
||||
url,
|
||||
title = 'Canlı Yayın',
|
||||
onClose,
|
||||
autoPlay = true
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
|
||||
hlsSupported: Hls.isSupported(),
|
||||
nativeHlsSupported: false,
|
||||
url: url,
|
||||
videoState: 'init',
|
||||
hlsState: 'none',
|
||||
error: null,
|
||||
bufferLength: 0
|
||||
})
|
||||
|
||||
const updateDebug = useCallback((updates: Partial<DebugInfo>) => {
|
||||
setDebugInfo(prev => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
updateDebug({ url, videoState: 'loading', error: null })
|
||||
|
||||
// Check native HLS support
|
||||
const nativeHls = video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||
updateDebug({ nativeHlsSupported: nativeHls })
|
||||
|
||||
// Determine stream type
|
||||
const isHlsStream = url.includes('.m3u8')
|
||||
const isTsStream = url.includes('.ts')
|
||||
const isLiveStream = url.includes('/live/')
|
||||
|
||||
console.log('[VideoPlayer] Stream info:', { url, isHlsStream, isTsStream, isLiveStream, hlsSupported: Hls.isSupported(), nativeHls })
|
||||
|
||||
// Cleanup previous HLS instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
|
||||
if ((isHlsStream || isLiveStream) && Hls.isSupported()) {
|
||||
// Use HLS.js for m3u8/live streams
|
||||
updateDebug({ hlsState: 'initializing' })
|
||||
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 90,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
startLevel: -1, // Auto quality
|
||||
debug: false,
|
||||
xhrSetup: (xhr, urlToLoad) => {
|
||||
// Add headers if needed
|
||||
xhr.withCredentials = false
|
||||
}
|
||||
})
|
||||
|
||||
hlsRef.current = hls
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
console.log('[HLS] Media attached')
|
||||
updateDebug({ hlsState: 'attached' })
|
||||
hls.loadSource(url)
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
console.log('[HLS] Manifest parsed, levels:', data.levels.length)
|
||||
updateDebug({ hlsState: 'manifest_parsed', videoState: 'ready' })
|
||||
setIsLoading(false)
|
||||
if (autoPlay) {
|
||||
video.play().catch(e => {
|
||||
console.warn('[HLS] Autoplay blocked:', e)
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_LOADING, () => {
|
||||
updateDebug({ hlsState: 'loading_manifest' })
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.LEVEL_LOADED, (_, data) => {
|
||||
console.log('[HLS] Level loaded, duration:', data.details.totalduration)
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||
updateDebug({ videoState: 'buffering' })
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.BUFFER_APPENDED, () => {
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferEnd = video.buffered.end(video.buffered.length - 1)
|
||||
updateDebug({ bufferLength: bufferEnd - video.currentTime })
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
console.error('[HLS] Error:', data.type, data.details, data)
|
||||
updateDebug({ hlsState: `error_${data.type}`, error: data.details })
|
||||
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
setError(`Ağ hatası: ${data.details}`)
|
||||
// Try to recover
|
||||
setTimeout(() => {
|
||||
console.log('[HLS] Attempting recovery from network error')
|
||||
hls.startLoad()
|
||||
}, 2000)
|
||||
break
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
setError(`Medya hatası: ${data.details}`)
|
||||
console.log('[HLS] Attempting recovery from media error')
|
||||
hls.recoverMediaError()
|
||||
break
|
||||
default:
|
||||
setError(`Stream yüklenemedi: ${data.details}`)
|
||||
break
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
hls.attachMedia(video)
|
||||
|
||||
} else if (nativeHls && isHlsStream) {
|
||||
// Native HLS support (Safari)
|
||||
console.log('[VideoPlayer] Using native HLS')
|
||||
updateDebug({ hlsState: 'native' })
|
||||
video.src = url
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false)
|
||||
updateDebug({ videoState: 'ready' })
|
||||
if (autoPlay) video.play().catch(() => setIsPlaying(false))
|
||||
})
|
||||
|
||||
} else {
|
||||
// Direct source (ts, mp4, etc.)
|
||||
console.log('[VideoPlayer] Using direct source')
|
||||
updateDebug({ hlsState: 'direct' })
|
||||
video.src = url
|
||||
|
||||
const handleCanPlay = () => {
|
||||
console.log('[Video] Can play')
|
||||
setIsLoading(false)
|
||||
updateDebug({ videoState: 'ready' })
|
||||
if (autoPlay) {
|
||||
video.play().catch(e => {
|
||||
console.warn('[Video] Autoplay blocked:', e)
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (e: Event) => {
|
||||
const videoError = video.error
|
||||
console.error('[Video] Error:', videoError)
|
||||
let errorMsg = 'Video yüklenemedi'
|
||||
|
||||
if (videoError) {
|
||||
switch (videoError.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMsg = 'Video yüklemesi iptal edildi'
|
||||
break
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMsg = 'Ağ hatası - Video indirilemedi'
|
||||
break
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMsg = 'Codec hatası - Video çözümlenemedi'
|
||||
break
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMsg = 'Format desteklenmiyor veya CORS hatası'
|
||||
break
|
||||
}
|
||||
if (videoError.message) {
|
||||
errorMsg += `: ${videoError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
setError(errorMsg)
|
||||
updateDebug({ videoState: 'error', error: errorMsg })
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', handleCanPlay)
|
||||
video.addEventListener('error', handleError)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('canplay', handleCanPlay)
|
||||
video.removeEventListener('error', handleError)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [url, autoPlay, updateDebug])
|
||||
|
||||
// Video event handlers
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true)
|
||||
updateDebug({ videoState: 'playing' })
|
||||
}
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false)
|
||||
updateDebug({ videoState: 'paused' })
|
||||
}
|
||||
const handleVolumeChange = () => {
|
||||
setVolume(video.volume)
|
||||
setIsMuted(video.muted)
|
||||
}
|
||||
const handleWaiting = () => {
|
||||
updateDebug({ videoState: 'buffering' })
|
||||
}
|
||||
|
||||
video.addEventListener('play', handlePlay)
|
||||
video.addEventListener('pause', handlePause)
|
||||
video.addEventListener('volumechange', handleVolumeChange)
|
||||
video.addEventListener('waiting', handleWaiting)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay)
|
||||
video.removeEventListener('pause', handlePause)
|
||||
video.removeEventListener('volumechange', handleVolumeChange)
|
||||
video.removeEventListener('waiting', handleWaiting)
|
||||
}
|
||||
}, [updateDebug])
|
||||
|
||||
// Auto-hide controls
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => setShowControls(false), 3000)
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
container.addEventListener('mousemove', handleMouseMove)
|
||||
container.addEventListener('mouseenter', handleMouseMove)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
if (container) {
|
||||
container.removeEventListener('mousemove', handleMouseMove)
|
||||
container.removeEventListener('mouseenter', handleMouseMove)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(e => console.error('Play failed:', e))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.muted = !video.muted
|
||||
}
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const newVolume = parseFloat(e.target.value)
|
||||
video.volume = newVolume
|
||||
video.muted = newVolume === 0
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen()
|
||||
setIsFullscreen(true)
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
})
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsLoading(false)
|
||||
video.play()
|
||||
})
|
||||
} else {
|
||||
video.src = url
|
||||
video.load()
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative bg-black rounded-xl overflow-hidden group"
|
||||
style={{ aspectRatio: '16/9' }}
|
||||
>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
<AnimatePresence>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-dark-900/80 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-12 h-12 text-accent-blue spinner mx-auto" />
|
||||
<p className="text-dark-300 mt-3">Yayın yükleniyor...</p>
|
||||
<p className="text-dark-500 text-xs mt-1">{debugInfo.hlsState}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error Overlay */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-dark-900/90 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center max-w-md px-4">
|
||||
<AlertCircle className="w-12 h-12 text-accent-red mx-auto" />
|
||||
<p className="text-accent-red mt-3 font-medium">{error}</p>
|
||||
<p className="text-dark-400 text-xs mt-2 break-all">{url}</p>
|
||||
<div className="flex gap-2 justify-center mt-4">
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Tekrar Dene
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="px-4 py-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Bug className="w-4 h-4" />
|
||||
Debug
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Debug Panel */}
|
||||
<AnimatePresence>
|
||||
{showDebug && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="absolute top-12 right-4 bg-dark-900/95 border border-dark-600 rounded-lg p-4 text-xs font-mono max-w-xs z-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-accent-blue font-semibold">Debug Info</span>
|
||||
<button onClick={() => setShowDebug(false)} className="text-dark-400 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 text-dark-300">
|
||||
<p><span className="text-dark-500">HLS.js:</span> {debugInfo.hlsSupported ? '✅' : '❌'}</p>
|
||||
<p><span className="text-dark-500">Native HLS:</span> {debugInfo.nativeHlsSupported ? '✅' : '❌'}</p>
|
||||
<p><span className="text-dark-500">State:</span> {debugInfo.hlsState}</p>
|
||||
<p><span className="text-dark-500">Video:</span> {debugInfo.videoState}</p>
|
||||
<p><span className="text-dark-500">Buffer:</span> {debugInfo.bufferLength.toFixed(1)}s</p>
|
||||
{debugInfo.error && (
|
||||
<p className="text-accent-red"><span className="text-dark-500">Error:</span> {debugInfo.error}</p>
|
||||
)}
|
||||
<p className="text-dark-500 break-all mt-2">URL: {debugInfo.url.substring(0, 50)}...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Controls Overlay */}
|
||||
<AnimatePresence>
|
||||
{showControls && !isLoading && !error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/40"
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<div className="absolute top-0 left-0 right-0 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tv2 className="w-5 h-5 text-accent-red" />
|
||||
<span className="text-white font-medium">{title}</span>
|
||||
<span className="px-2 py-0.5 bg-accent-red text-white text-xs rounded">CANLI</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className={`p-2 rounded-lg transition-colors ${showDebug ? 'bg-accent-blue/30 text-accent-blue' : 'bg-dark-800/50 hover:bg-dark-700 text-white'}`}
|
||||
title="Debug Paneli"
|
||||
>
|
||||
<Bug className="w-4 h-4" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Play Button */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-20 h-20 rounded-full bg-accent-blue/80 hover:bg-accent-blue flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Play className="w-10 h-10 text-white ml-1" fill="white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<VolumeX className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-dark-600 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reload */}
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||
title="Yeniden Yükle"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<Maximize className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/globals.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Tv2,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Link2,
|
||||
FolderOpen,
|
||||
Play
|
||||
} from 'lucide-react'
|
||||
import { useStore } from '../store/useStore'
|
||||
import VideoPlayer from '../components/VideoPlayer'
|
||||
import type { Channel, ChannelStatus } from '@shared/types'
|
||||
|
||||
// Status Badge Component
|
||||
function StatusBadge({ status }: { status: ChannelStatus }) {
|
||||
const config = {
|
||||
online: { icon: CheckCircle2, text: 'Çevrimiçi', color: 'text-accent-green bg-accent-green/10' },
|
||||
offline: { icon: XCircle, text: 'Çevrimdışı', color: 'text-accent-red bg-accent-red/10' },
|
||||
error: { icon: AlertCircle, text: 'Hata', color: 'text-accent-orange bg-accent-orange/10' },
|
||||
checking: { icon: Loader2, text: 'Kontrol', color: 'text-accent-blue bg-accent-blue/10' },
|
||||
unknown: { icon: AlertCircle, text: 'Bilinmiyor', color: 'text-dark-400 bg-dark-600' },
|
||||
}
|
||||
|
||||
const { icon: Icon, text, color } = config[status]
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium ${color}`}>
|
||||
<Icon className={`w-3 h-3 ${status === 'checking' ? 'spinner' : ''}`} />
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Channel Row Component
|
||||
function ChannelRow({
|
||||
channel,
|
||||
onToggleMonitor,
|
||||
onWatch
|
||||
}: {
|
||||
channel: Channel
|
||||
onToggleMonitor: (id: string, monitored: boolean) => void
|
||||
onWatch: (channel: Channel) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-dark-700/50 border border-dark-600 rounded-lg p-4 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo/Icon */}
|
||||
<div className="w-10 h-10 rounded-lg bg-dark-600 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{channel.logo ? (
|
||||
<img src={channel.logo} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Tv2 className="w-5 h-5 text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-dark-100 truncate">{channel.name}</h3>
|
||||
{channel.group && (
|
||||
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded">
|
||||
{channel.group}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-dark-400 truncate mt-1 font-mono">{channel.url}</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<StatusBadge status={channel.status} />
|
||||
|
||||
{/* Latency */}
|
||||
{channel.latency && (
|
||||
<span className="text-xs text-dark-400 font-mono w-16 text-right">
|
||||
{channel.latency}ms
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Watch Button */}
|
||||
<button
|
||||
onClick={() => onWatch(channel)}
|
||||
className="p-2 rounded-lg bg-accent-green/20 text-accent-green hover:bg-accent-green/30 transition-colors"
|
||||
title="Canlı İzle"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Monitor Toggle */}
|
||||
<button
|
||||
onClick={() => onToggleMonitor(channel.id, !channel.isMonitored)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
channel.isMonitored
|
||||
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
|
||||
: 'bg-dark-600 text-dark-400 hover:bg-dark-500'
|
||||
}`}
|
||||
title={channel.isMonitored ? 'İzlemeyi durdur' : 'İzlemeye başla'}
|
||||
>
|
||||
{channel.isMonitored ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{channel.lastError && (
|
||||
<div className="mt-3 p-2 bg-accent-red/10 border border-accent-red/20 rounded-md">
|
||||
<p className="text-xs text-accent-red">{channel.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add Playlist/URL Modal
|
||||
function AddPlaylistModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
onAddSingle
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onAdd: (url: string) => void
|
||||
onAddSingle: (name: string, url: string) => void
|
||||
}) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [channelName, setChannelName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mode, setMode] = useState<'playlist' | 'single'>('playlist')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
if (mode === 'playlist') {
|
||||
await onAdd(url.trim())
|
||||
} else {
|
||||
await onAddSingle(channelName.trim() || 'Yeni Kanal', url.trim())
|
||||
}
|
||||
setLoading(false)
|
||||
setUrl('')
|
||||
setChannelName('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-dark-800 border border-dark-600 rounded-xl p-6 w-full max-w-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-dark-100 mb-4 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-accent-blue" />
|
||||
Kanal/Playlist Ekle
|
||||
</h2>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('playlist')}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
mode === 'playlist'
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
M3U8 Playlist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('single')}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
mode === 'single'
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
Tek URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === 'single' && (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">Kanal Adı</label>
|
||||
<input
|
||||
type="text"
|
||||
value={channelName}
|
||||
onChange={(e) => setChannelName(e.target.value)}
|
||||
placeholder="Örn: TRT 1 HD"
|
||||
className="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">
|
||||
{mode === 'playlist' ? 'Playlist URL' : 'Stream URL'}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={mode === 'playlist'
|
||||
? "https://example.com/playlist.m3u8"
|
||||
: "http://example.com:8080/live/stream.ts"
|
||||
}
|
||||
className="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url.trim() || loading}
|
||||
className="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 spinner" />}
|
||||
{mode === 'playlist' ? 'Playlist Yükle' : 'Kanal Ekle'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Channels() {
|
||||
const { channels, setChannels, channelsLoading, setChannelsLoading } = useStore()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState<ChannelStatus | 'all'>('all')
|
||||
const [showMonitoredOnly, setShowMonitoredOnly] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [watchingChannel, setWatchingChannel] = useState<Channel | null>(null)
|
||||
|
||||
// Load channels on mount
|
||||
useEffect(() => {
|
||||
const loadChannels = async () => {
|
||||
if (window.electron) {
|
||||
setChannelsLoading(true)
|
||||
try {
|
||||
const loadedChannels = await window.electron.getChannels()
|
||||
setChannels(loadedChannels)
|
||||
} catch (error) {
|
||||
console.error('Failed to load channels:', error)
|
||||
} finally {
|
||||
setChannelsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadChannels()
|
||||
}, [])
|
||||
|
||||
const handleAddPlaylist = async (url: string) => {
|
||||
if (window.electron) {
|
||||
setChannelsLoading(true)
|
||||
try {
|
||||
const newChannels = await window.electron.parseM3u8(url)
|
||||
setChannels([...channels, ...newChannels])
|
||||
} catch (error) {
|
||||
console.error('Failed to parse playlist:', error)
|
||||
} finally {
|
||||
setChannelsLoading(false)
|
||||
}
|
||||
} else {
|
||||
// Demo data
|
||||
const demoChannels: Channel[] = [
|
||||
{ id: '1', name: 'TRT 1 HD', url: 'https://tv-trt1.medya.trt.com.tr/master.m3u8', group: 'Ulusal', isMonitored: true, status: 'online', latency: 45 },
|
||||
{ id: '2', name: 'Show TV', url: 'https://ciner-live.daioncdn.net/showtv/showtv.m3u8', group: 'Ulusal', isMonitored: true, status: 'online', latency: 67 },
|
||||
{ id: '3', name: 'ATV', url: 'https://trkvz-live.ercdn.net/atvhd/atvhd.m3u8', group: 'Ulusal', isMonitored: false, status: 'unknown' },
|
||||
{ id: '4', name: 'TV8', url: 'https://tv8-live.ercdn.net/tv8/tv8.m3u8', group: 'Ulusal', isMonitored: true, status: 'error', lastError: 'ERR_CONNECTION_RESET' },
|
||||
{ id: '5', name: 'CNN Türk', url: 'https://cnn.com/live.m3u8', group: 'Haber', isMonitored: true, status: 'offline' },
|
||||
]
|
||||
setChannels([...channels, ...demoChannels])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSingleChannel = async (name: string, url: string) => {
|
||||
const newChannel: Channel = {
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
url,
|
||||
group: 'Manuel',
|
||||
isMonitored: true,
|
||||
status: 'unknown'
|
||||
}
|
||||
setChannels([...channels, newChannel])
|
||||
}
|
||||
|
||||
const handleToggleMonitor = async (channelId: string, monitored: boolean) => {
|
||||
if (window.electron) {
|
||||
await window.electron.setChannelMonitored(channelId, monitored)
|
||||
}
|
||||
setChannels(
|
||||
channels.map((c) =>
|
||||
c.id === channelId ? { ...c, isMonitored: monitored } : c
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleWatch = (channel: Channel) => {
|
||||
setWatchingChannel(channel)
|
||||
}
|
||||
|
||||
// Filter channels
|
||||
const filteredChannels = channels.filter((channel) => {
|
||||
const matchesSearch = channel.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
channel.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesStatus = filterStatus === 'all' || channel.status === filterStatus
|
||||
const matchesMonitored = !showMonitoredOnly || channel.isMonitored
|
||||
return matchesSearch && matchesStatus && matchesMonitored
|
||||
})
|
||||
|
||||
// Group channels by group
|
||||
const groupedChannels = filteredChannels.reduce((acc, channel) => {
|
||||
const group = channel.group || 'Diğer'
|
||||
if (!acc[group]) acc[group] = []
|
||||
acc[group].push(channel)
|
||||
return acc
|
||||
}, {} as Record<string, Channel[]>)
|
||||
|
||||
const monitoredCount = channels.filter(c => c.isMonitored).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-dark-100">Kanal Yönetimi</h1>
|
||||
<p className="text-dark-400 text-sm mt-1">
|
||||
{channels.length} kanal · {monitoredCount} izleniyor
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Playlist Ekle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Kanal ara..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as ChannelStatus | 'all')}
|
||||
className="px-4 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="all">Tüm Durumlar</option>
|
||||
<option value="online">Çevrimiçi</option>
|
||||
<option value="offline">Çevrimdışı</option>
|
||||
<option value="error">Hatalı</option>
|
||||
<option value="unknown">Bilinmiyor</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setShowMonitoredOnly(!showMonitoredOnly)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
|
||||
showMonitoredOnly
|
||||
? 'bg-accent-blue/20 border-accent-blue/30 text-accent-blue'
|
||||
: 'bg-dark-700 border-dark-600 text-dark-300 hover:text-dark-100'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Sadece İzlenen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Channel List */}
|
||||
{channelsLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-accent-blue spinner" />
|
||||
</div>
|
||||
) : filteredChannels.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center py-20 text-dark-400"
|
||||
>
|
||||
<FolderOpen className="w-16 h-16 mb-4 text-dark-600" />
|
||||
<p className="text-lg">Henüz kanal eklenmemiş</p>
|
||||
<p className="text-sm mt-2">M3U8 playlist URL'i ekleyerek başlayın</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedChannels).map(([group, groupChannels]) => (
|
||||
<div key={group}>
|
||||
<h3 className="text-sm font-medium text-dark-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent-purple" />
|
||||
{group}
|
||||
<span className="text-dark-500">({groupChannels.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{groupChannels.map((channel) => (
|
||||
<ChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
onToggleMonitor={handleToggleMonitor}
|
||||
onWatch={handleWatch}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Playlist Modal */}
|
||||
<AnimatePresence>
|
||||
{isModalOpen && (
|
||||
<AddPlaylistModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onAdd={handleAddPlaylist}
|
||||
onAddSingle={handleAddSingleChannel}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Video Player Modal */}
|
||||
<AnimatePresence>
|
||||
{watchingChannel && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-8"
|
||||
onClick={() => setWatchingChannel(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="w-full max-w-5xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VideoPlayer
|
||||
url={watchingChannel.url}
|
||||
title={watchingChannel.name}
|
||||
onClose={() => setWatchingChannel(null)}
|
||||
autoPlay={true}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Globe,
|
||||
MapPin,
|
||||
Wifi,
|
||||
Tv2,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Clock,
|
||||
Bell
|
||||
} from 'lucide-react'
|
||||
import { useStore } from '../store/useStore'
|
||||
import type { IpInfo, ErrorLogEntry } from '@shared/types'
|
||||
|
||||
// Status Card Component
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
color = 'blue',
|
||||
delay = 0
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
|
||||
delay?: number
|
||||
}) {
|
||||
const colorClasses = {
|
||||
blue: 'from-accent-blue/20 to-accent-blue/5 border-accent-blue/30 text-accent-blue',
|
||||
green: 'from-accent-green/20 to-accent-green/5 border-accent-green/30 text-accent-green',
|
||||
red: 'from-accent-red/20 to-accent-red/5 border-accent-red/30 text-accent-red',
|
||||
yellow: 'from-accent-yellow/20 to-accent-yellow/5 border-accent-yellow/30 text-accent-yellow',
|
||||
purple: 'from-accent-purple/20 to-accent-purple/5 border-accent-purple/30 text-accent-purple',
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay, duration: 0.3 }}
|
||||
className={`bg-gradient-to-br ${colorClasses[color]} border rounded-xl p-5 card-hover`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-dark-400 text-xs uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-dark-100">{value}</p>
|
||||
{subValue && <p className="text-xs text-dark-400 mt-1">{subValue}</p>}
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg bg-dark-800/50`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// IP Info Card Component
|
||||
function IpInfoCard({ ipInfo, loading, onRefresh }: {
|
||||
ipInfo: IpInfo | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-dark-800 border border-dark-700 rounded-xl p-6 col-span-2"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-accent-blue" />
|
||||
IP Bilgileri
|
||||
</h2>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-dark-300 ${loading ? 'spinner' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-8 h-8 border-2 border-accent-blue border-t-transparent rounded-full spinner" />
|
||||
</div>
|
||||
) : ipInfo ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400 text-xs mb-2">
|
||||
<Wifi className="w-4 h-4" />
|
||||
IP Adresi
|
||||
</div>
|
||||
<p className="text-xl font-mono font-bold text-accent-cyan">{ipInfo.ip}</p>
|
||||
</div>
|
||||
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400 text-xs mb-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Konum
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-dark-100">
|
||||
{ipInfo.country}
|
||||
<span className="ml-2 text-sm bg-dark-600 px-2 py-0.5 rounded">
|
||||
{ipInfo.countryCode}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{ipInfo.city && (
|
||||
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||
<div className="text-dark-400 text-xs mb-2">Şehir</div>
|
||||
<p className="text-lg font-semibold text-dark-100">{ipInfo.city}</p>
|
||||
</div>
|
||||
)}
|
||||
{ipInfo.isp && (
|
||||
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||
<div className="text-dark-400 text-xs mb-2">ISP</div>
|
||||
<p className="text-lg font-semibold text-dark-100 truncate">{ipInfo.isp}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-dark-400">
|
||||
IP bilgisi alınamadı
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Recent Errors Component
|
||||
function RecentErrors({ errors }: { errors: ErrorLogEntry[] }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-dark-800 border border-dark-700 rounded-xl p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-dark-100 flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-accent-red" />
|
||||
Son Hatalar
|
||||
</h2>
|
||||
|
||||
{errors.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-dark-400">
|
||||
<CheckCircle2 className="w-12 h-12 mb-3 text-accent-green" />
|
||||
<p>Hata bulunamadı</p>
|
||||
<p className="text-xs mt-1">Tüm sistemler normal çalışıyor</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-auto">
|
||||
{errors.slice(0, 5).map((error, index) => (
|
||||
<motion.div
|
||||
key={error.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="bg-dark-700/50 border border-dark-600 rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-dark-100 truncate">{error.channelName}</p>
|
||||
<p className="text-xs text-accent-red mt-1">{error.errorType}</p>
|
||||
<p className="text-xs text-dark-400 truncate mt-1">{error.errorMessage}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{error.notified && (
|
||||
<Bell className="w-3 h-3 text-accent-green" />
|
||||
)}
|
||||
<span className="text-xs text-dark-500">
|
||||
{new Date(error.timestamp).toLocaleTimeString('tr-TR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const {
|
||||
ipInfo,
|
||||
ipLoading,
|
||||
setIpInfo,
|
||||
setIpLoading,
|
||||
channels,
|
||||
errorLog,
|
||||
isMonitoring
|
||||
} = useStore()
|
||||
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
|
||||
|
||||
const fetchIpInfo = async () => {
|
||||
setIpLoading(true)
|
||||
try {
|
||||
if (window.electron) {
|
||||
const info = await window.electron.getIpInfo()
|
||||
setIpInfo(info)
|
||||
} else {
|
||||
// Demo data for development
|
||||
setIpInfo({
|
||||
ip: '185.123.45.67',
|
||||
country: 'Turkey',
|
||||
countryCode: 'TR',
|
||||
city: 'Istanbul',
|
||||
isp: 'Turk Telekom'
|
||||
})
|
||||
}
|
||||
setLastUpdate(new Date())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch IP info:', error)
|
||||
} finally {
|
||||
setIpLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchIpInfo()
|
||||
}, [])
|
||||
|
||||
const monitoredChannels = channels.filter(c => c.isMonitored)
|
||||
const onlineChannels = channels.filter(c => c.status === 'online')
|
||||
const errorChannels = channels.filter(c => c.status === 'error' || c.status === 'offline')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-dark-100">Dashboard</h1>
|
||||
<p className="text-dark-400 text-sm mt-1">
|
||||
Sistem durumu ve özet bilgiler
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-dark-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
Son güncelleme: {lastUpdate.toLocaleTimeString('tr-TR')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Tv2}
|
||||
label="Toplam Kanal"
|
||||
value={channels.length}
|
||||
subValue={`${monitoredChannels.length} izleniyor`}
|
||||
color="blue"
|
||||
delay={0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle2}
|
||||
label="Çevrimiçi"
|
||||
value={onlineChannels.length}
|
||||
subValue="Aktif yayınlar"
|
||||
color="green"
|
||||
delay={0.1}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Hatalı"
|
||||
value={errorChannels.length}
|
||||
subValue={`${errorLog.length} log kaydı`}
|
||||
color="red"
|
||||
delay={0.2}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="İzleme Durumu"
|
||||
value={isMonitoring ? 'Aktif' : 'Pasif'}
|
||||
subValue={isMonitoring ? 'Otomatik kontrol' : 'Beklemede'}
|
||||
color={isMonitoring ? 'green' : 'yellow'}
|
||||
delay={0.3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<IpInfoCard ipInfo={ipInfo} loading={ipLoading} onRefresh={fetchIpInfo} />
|
||||
<RecentErrors errors={errorLog} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Activity,
|
||||
Play,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Wifi,
|
||||
Bell,
|
||||
BellOff,
|
||||
Loader2,
|
||||
Signal,
|
||||
TrendingUp,
|
||||
Tv2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useStore } from '../store/useStore'
|
||||
import VideoPlayer from '../components/VideoPlayer'
|
||||
import type { Channel, MonitoringResult } from '@shared/types'
|
||||
|
||||
// Live Status Indicator
|
||||
function LiveIndicator({ isLive }: { isLive: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium ${
|
||||
isLive ? 'bg-accent-green/20 text-accent-green' : 'bg-dark-600 text-dark-400'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${isLive ? 'bg-accent-green status-online' : 'bg-dark-500'}`} />
|
||||
{isLive ? 'Canlı İzleme' : 'Durduruldu'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Channel Status Card
|
||||
function ChannelStatusCard({
|
||||
channel,
|
||||
result,
|
||||
onManualNotify,
|
||||
onWatch
|
||||
}: {
|
||||
channel: Channel
|
||||
result?: MonitoringResult
|
||||
onManualNotify: (channel: Channel) => void
|
||||
onWatch: (channel: Channel) => void
|
||||
}) {
|
||||
const statusConfig = {
|
||||
online: { icon: CheckCircle2, color: 'text-accent-green', bg: 'bg-accent-green/10 border-accent-green/20' },
|
||||
offline: { icon: XCircle, color: 'text-accent-red', bg: 'bg-accent-red/10 border-accent-red/20' },
|
||||
error: { icon: AlertTriangle, color: 'text-accent-orange', bg: 'bg-accent-orange/10 border-accent-orange/20' },
|
||||
checking: { icon: Loader2, color: 'text-accent-blue', bg: 'bg-accent-blue/10 border-accent-blue/20' },
|
||||
unknown: { icon: Signal, color: 'text-dark-400', bg: 'bg-dark-700 border-dark-600' },
|
||||
}
|
||||
|
||||
const status = result?.status || channel.status
|
||||
const config = statusConfig[status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={`border rounded-xl p-4 ${config.bg} card-hover`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-dark-800/50`}>
|
||||
<Icon className={`w-5 h-5 ${config.color} ${status === 'checking' ? 'spinner' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-dark-100">{channel.name}</h3>
|
||||
<p className="text-xs text-dark-400 mt-0.5">{channel.group || 'Genel'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Watch Button */}
|
||||
<button
|
||||
onClick={() => onWatch(channel)}
|
||||
className="p-2 rounded-lg bg-accent-blue/20 hover:bg-accent-blue/30 transition-colors"
|
||||
title="Canlı İzle"
|
||||
>
|
||||
<Tv2 className="w-4 h-4 text-accent-blue" />
|
||||
</button>
|
||||
|
||||
{/* Manual Notify Button */}
|
||||
{(status === 'error' || status === 'offline') && (
|
||||
<button
|
||||
onClick={() => onManualNotify(channel)}
|
||||
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||
title="Manuel Bildirim Gönder"
|
||||
>
|
||||
<Bell className="w-4 h-4 text-accent-yellow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="bg-dark-800/30 rounded-lg p-2">
|
||||
<div className="text-xs text-dark-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Gecikme
|
||||
</div>
|
||||
<p className="text-sm font-mono text-dark-100 mt-1">
|
||||
{result?.latency ? `${result.latency}ms` : channel.latency ? `${channel.latency}ms` : '---'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-dark-800/30 rounded-lg p-2">
|
||||
<div className="text-xs text-dark-400 flex items-center gap-1">
|
||||
<Wifi className="w-3 h-3" />
|
||||
HTTP
|
||||
</div>
|
||||
<p className={`text-sm font-mono mt-1 ${
|
||||
result?.httpStatus === 200 ? 'text-accent-green' :
|
||||
result?.httpStatus ? 'text-accent-red' : 'text-dark-400'
|
||||
}`}>
|
||||
{result?.httpStatus || '---'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{result?.error && (
|
||||
<div className="mt-3 p-2 bg-dark-800/50 rounded-lg border border-dark-600">
|
||||
<p className="text-xs text-accent-red font-mono">{result.errorType}</p>
|
||||
<p className="text-xs text-dark-400 mt-1 truncate">{result.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Check Time */}
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-dark-500">
|
||||
<span>Son kontrol:</span>
|
||||
<span>
|
||||
{result?.timestamp
|
||||
? new Date(result.timestamp).toLocaleTimeString('tr-TR')
|
||||
: channel.lastCheck
|
||||
? new Date(channel.lastCheck).toLocaleTimeString('tr-TR')
|
||||
: '---'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stats Overview
|
||||
function StatsOverview({
|
||||
total,
|
||||
online,
|
||||
offline,
|
||||
errors
|
||||
}: {
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
errors: number
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400 text-xs">
|
||||
<Signal className="w-4 h-4" />
|
||||
Toplam İzlenen
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-dark-100 mt-2">{total}</p>
|
||||
</div>
|
||||
<div className="bg-accent-green/10 border border-accent-green/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-accent-green text-xs">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Çevrimiçi
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-accent-green mt-2">{online}</p>
|
||||
</div>
|
||||
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-accent-red text-xs">
|
||||
<XCircle className="w-4 h-4" />
|
||||
Çevrimdışı
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-accent-red mt-2">{offline}</p>
|
||||
</div>
|
||||
<div className="bg-accent-orange/10 border border-accent-orange/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-accent-orange text-xs">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Hata
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-accent-orange mt-2">{errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Monitoring() {
|
||||
const {
|
||||
channels,
|
||||
isMonitoring,
|
||||
setIsMonitoring,
|
||||
monitoringResults,
|
||||
updateMonitoringResult,
|
||||
settings
|
||||
} = useStore()
|
||||
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
|
||||
const [watchingChannel, setWatchingChannel] = useState<Channel | null>(null)
|
||||
|
||||
const monitoredChannels = channels.filter(c => c.isMonitored)
|
||||
|
||||
// Subscribe to monitoring updates
|
||||
useEffect(() => {
|
||||
if (window.electron) {
|
||||
const unsubscribe = window.electron.onMonitoringUpdate((result) => {
|
||||
updateMonitoringResult(result)
|
||||
setLastRefresh(new Date())
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggleMonitoring = async () => {
|
||||
if (window.electron) {
|
||||
if (isMonitoring) {
|
||||
await window.electron.stopMonitoring()
|
||||
} else {
|
||||
await window.electron.startMonitoring()
|
||||
}
|
||||
}
|
||||
setIsMonitoring(!isMonitoring)
|
||||
}
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
// Trigger immediate check of all channels
|
||||
if (window.electron && isMonitoring) {
|
||||
// This would trigger a manual check cycle
|
||||
}
|
||||
setLastRefresh(new Date())
|
||||
}
|
||||
|
||||
const handleManualNotify = async (channel: Channel) => {
|
||||
if (window.electron) {
|
||||
const result = monitoringResults.get(channel.id)
|
||||
await window.electron.sendNotification(
|
||||
`🔴 ${channel.name} - Yayın Hatası`,
|
||||
result?.error || channel.lastError || 'Yayın erişilemez durumda'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWatch = (channel: Channel) => {
|
||||
setWatchingChannel(channel)
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: monitoredChannels.length,
|
||||
online: monitoredChannels.filter(c => {
|
||||
const result = monitoringResults.get(c.id)
|
||||
return (result?.status || c.status) === 'online'
|
||||
}).length,
|
||||
offline: monitoredChannels.filter(c => {
|
||||
const result = monitoringResults.get(c.id)
|
||||
return (result?.status || c.status) === 'offline'
|
||||
}).length,
|
||||
errors: monitoredChannels.filter(c => {
|
||||
const result = monitoringResults.get(c.id)
|
||||
return (result?.status || c.status) === 'error'
|
||||
}).length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-dark-100 flex items-center gap-3">
|
||||
<Activity className="w-7 h-7 text-accent-purple" />
|
||||
Canlı İzleme
|
||||
</h1>
|
||||
<p className="text-dark-400 text-sm mt-1">
|
||||
Son güncelleme: {lastRefresh.toLocaleTimeString('tr-TR')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<LiveIndicator isLive={isMonitoring} />
|
||||
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
disabled={!isMonitoring}
|
||||
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 transition-colors"
|
||||
title="Yenile"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5 text-dark-300" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleToggleMonitoring}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
isMonitoring
|
||||
? 'bg-accent-red/20 text-accent-red hover:bg-accent-red/30'
|
||||
: 'bg-accent-green text-white hover:bg-accent-green/80'
|
||||
}`}
|
||||
>
|
||||
{isMonitoring ? (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
Durdur
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Başlat
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<StatsOverview {...stats} />
|
||||
|
||||
{/* Auto Notification Status */}
|
||||
<div className="flex items-center justify-between bg-dark-800 border border-dark-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{settings?.notifications.autoNotify ? (
|
||||
<Bell className="w-5 h-5 text-accent-green" />
|
||||
) : (
|
||||
<BellOff className="w-5 h-5 text-dark-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">Otomatik Bildirimler</p>
|
||||
<p className="text-xs text-dark-400 mt-0.5">
|
||||
{settings?.notifications.autoNotify
|
||||
? 'Hata durumunda otomatik bildirim gönderilecek'
|
||||
: 'Otomatik bildirimler kapalı - Ayarlardan etkinleştirin'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TrendingUp className={`w-5 h-5 ${settings?.notifications.autoNotify ? 'text-accent-green' : 'text-dark-500'}`} />
|
||||
</div>
|
||||
|
||||
{/* Monitored Channels Grid */}
|
||||
{monitoredChannels.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center py-20 text-dark-400"
|
||||
>
|
||||
<Activity className="w-16 h-16 mb-4 text-dark-600" />
|
||||
<p className="text-lg">Henüz izlenen kanal yok</p>
|
||||
<p className="text-sm mt-2">Kanallar sayfasından izlemek istediğiniz kanalları seçin</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{monitoredChannels.map((channel) => (
|
||||
<ChannelStatusCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
result={monitoringResults.get(channel.id)}
|
||||
onManualNotify={handleManualNotify}
|
||||
onWatch={handleWatch}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Player Modal */}
|
||||
<AnimatePresence>
|
||||
{watchingChannel && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-8"
|
||||
onClick={() => setWatchingChannel(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="w-full max-w-5xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VideoPlayer
|
||||
url={watchingChannel.url}
|
||||
title={watchingChannel.name}
|
||||
onClose={() => setWatchingChannel(null)}
|
||||
autoPlay={true}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Bell,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
Server,
|
||||
Save,
|
||||
TestTube,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
Shield,
|
||||
HardDrive,
|
||||
Info
|
||||
} from 'lucide-react'
|
||||
import { useStore } from '../store/useStore'
|
||||
import type { AppSettings, NotificationType } from '@shared/types'
|
||||
|
||||
// Section Component
|
||||
function SettingsSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
children
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ElementType
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-dark-800 border border-dark-700 rounded-xl p-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-dark-100 flex items-center gap-2 mb-4">
|
||||
<Icon className="w-5 h-5 text-accent-purple" />
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle Switch
|
||||
function Toggle({
|
||||
enabled,
|
||||
onChange
|
||||
}: {
|
||||
enabled: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
enabled ? 'bg-accent-blue' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Input Field
|
||||
function InputField({
|
||||
label,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
placeholder
|
||||
}: {
|
||||
label: string
|
||||
type?: string
|
||||
value: string | number
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-4 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Test Button
|
||||
function TestButton({
|
||||
type,
|
||||
onTest
|
||||
}: {
|
||||
type: NotificationType
|
||||
onTest: (type: NotificationType) => Promise<void>
|
||||
}) {
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [result, setResult] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setResult(null)
|
||||
try {
|
||||
await onTest(type)
|
||||
setResult('success')
|
||||
} catch {
|
||||
setResult('error')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
setTimeout(() => setResult(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-dark-600 text-dark-300 rounded-lg hover:bg-dark-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="w-4 h-4 spinner" />
|
||||
) : result === 'success' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-accent-green" />
|
||||
) : result === 'error' ? (
|
||||
<XCircle className="w-4 h-4 text-accent-red" />
|
||||
) : (
|
||||
<TestTube className="w-4 h-4" />
|
||||
)}
|
||||
Test
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { settings, setSettings } = useStore()
|
||||
const [localSettings, setLocalSettings] = useState<AppSettings | null>(settings)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [appInfo, setAppInfo] = useState<{ version: string; isPortable: boolean; dataPath: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings)
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
const loadAppInfo = async () => {
|
||||
if (window.electron?.getAppInfo) {
|
||||
const info = await window.electron.getAppInfo()
|
||||
setAppInfo(info)
|
||||
}
|
||||
}
|
||||
loadAppInfo()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!localSettings) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (window.electron) {
|
||||
await window.electron.saveSettings(localSettings)
|
||||
}
|
||||
setSettings(localSettings)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestNotification = async (type: NotificationType) => {
|
||||
if (window.electron) {
|
||||
await window.electron.testNotification(type)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSettings = (path: string, value: unknown) => {
|
||||
if (!localSettings) return
|
||||
|
||||
const keys = path.split('.')
|
||||
const newSettings = { ...localSettings }
|
||||
let current: Record<string, unknown> = newSettings as Record<string, unknown>
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
current[keys[i]] = { ...(current[keys[i]] as Record<string, unknown>) }
|
||||
current = current[keys[i]] as Record<string, unknown>
|
||||
}
|
||||
current[keys[keys.length - 1]] = value
|
||||
|
||||
setLocalSettings(newSettings as AppSettings)
|
||||
}
|
||||
|
||||
if (!localSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 text-accent-blue spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-bold text-dark-100 flex items-center gap-3">
|
||||
<SettingsIcon className="w-7 h-7 text-accent-purple" />
|
||||
Ayarlar
|
||||
</h1>
|
||||
<p className="text-dark-400 text-sm mt-1">
|
||||
Uygulama ve bildirim ayarlarını yapılandırın
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 spinner" />
|
||||
) : saved ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{saved ? 'Kaydedildi!' : 'Kaydet'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* General Settings */}
|
||||
<SettingsSection title="Genel Ayarlar" icon={Monitor}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Başlangıçta minimize</p>
|
||||
<p className="text-xs text-dark-400">Uygulama açıldığında küçültülmüş başla</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.general.startMinimized}
|
||||
onChange={(v) => updateSettings('general.startMinimized', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Tray'e küçült</p>
|
||||
<p className="text-xs text-dark-400">Kapatma yerine system tray'e taşı</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.general.minimizeToTray}
|
||||
onChange={(v) => updateSettings('general.minimizeToTray', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Monitoring Settings */}
|
||||
<SettingsSection title="İzleme Ayarları" icon={Clock}>
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
label="Ping Aralığı (saniye)"
|
||||
type="number"
|
||||
value={localSettings.monitoring.pingInterval}
|
||||
onChange={(v) => updateSettings('monitoring.pingInterval', parseInt(v) || 30)}
|
||||
/>
|
||||
<InputField
|
||||
label="Stream Kontrol Aralığı (saniye)"
|
||||
type="number"
|
||||
value={localSettings.monitoring.streamCheckInterval}
|
||||
onChange={(v) => updateSettings('monitoring.streamCheckInterval', parseInt(v) || 60)}
|
||||
/>
|
||||
<InputField
|
||||
label="Zaman Aşımı (ms)"
|
||||
type="number"
|
||||
value={localSettings.monitoring.timeout}
|
||||
onChange={(v) => updateSettings('monitoring.timeout', parseInt(v) || 5000)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Windows Notifications */}
|
||||
<SettingsSection title="Windows Bildirimleri" icon={Bell}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Etkin</p>
|
||||
<p className="text-xs text-dark-400">Windows toast bildirimleri</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<TestButton type="windows" onTest={handleTestNotification} />
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.windows.enabled}
|
||||
onChange={(v) => updateSettings('notifications.windows.enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Telegram Settings */}
|
||||
<SettingsSection title="Telegram Bildirimleri" icon={MessageCircle}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Etkin</p>
|
||||
<p className="text-xs text-dark-400">Telegram bot bildirimleri</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<TestButton type="telegram" onTest={handleTestNotification} />
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.telegram.enabled}
|
||||
onChange={(v) => updateSettings('notifications.telegram.enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InputField
|
||||
label="Bot Token"
|
||||
value={localSettings.notifications.telegram.botToken}
|
||||
onChange={(v) => updateSettings('notifications.telegram.botToken', v)}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
/>
|
||||
<InputField
|
||||
label="Chat ID"
|
||||
value={localSettings.notifications.telegram.chatId}
|
||||
onChange={(v) => updateSettings('notifications.telegram.chatId', v)}
|
||||
placeholder="-1001234567890"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Email Settings */}
|
||||
<SettingsSection title="Email Bildirimleri" icon={Mail}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Etkin</p>
|
||||
<p className="text-xs text-dark-400">SMTP email bildirimleri</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<TestButton type="email" onTest={handleTestNotification} />
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.email.enabled}
|
||||
onChange={(v) => updateSettings('notifications.email.enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="SMTP Sunucu"
|
||||
value={localSettings.notifications.email.smtpHost}
|
||||
onChange={(v) => updateSettings('notifications.email.smtpHost', v)}
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
<InputField
|
||||
label="Port"
|
||||
type="number"
|
||||
value={localSettings.notifications.email.smtpPort}
|
||||
onChange={(v) => updateSettings('notifications.email.smtpPort', parseInt(v) || 587)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Kullanıcı"
|
||||
value={localSettings.notifications.email.smtpUser}
|
||||
onChange={(v) => updateSettings('notifications.email.smtpUser', v)}
|
||||
/>
|
||||
<InputField
|
||||
label="Şifre"
|
||||
type="password"
|
||||
value={localSettings.notifications.email.smtpPass}
|
||||
onChange={(v) => updateSettings('notifications.email.smtpPass', v)}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
label="Alıcı Email"
|
||||
value={localSettings.notifications.email.toEmail}
|
||||
onChange={(v) => updateSettings('notifications.email.toEmail', v)}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Panel Integration */}
|
||||
<SettingsSection title="Panel Entegrasyonu" icon={Server}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Etkin</p>
|
||||
<p className="text-xs text-dark-400">Monitor API entegrasyonu</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.panel.enabled}
|
||||
onChange={(v) => updateSettings('panel.enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
label="API URL"
|
||||
value={localSettings.panel.apiUrl}
|
||||
onChange={(v) => updateSettings('panel.apiUrl', v)}
|
||||
placeholder="https://panel.example.com/attacker_detection"
|
||||
/>
|
||||
<InputField
|
||||
label="Monitor ID"
|
||||
value={localSettings.panel.monitorId}
|
||||
onChange={(v) => updateSettings('panel.monitorId', v)}
|
||||
placeholder="monitor-1"
|
||||
/>
|
||||
<InputField
|
||||
label="Auth Token"
|
||||
type="password"
|
||||
value={localSettings.panel.authToken}
|
||||
onChange={(v) => updateSettings('panel.authToken', v)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Notification Rules */}
|
||||
<SettingsSection title="Bildirim Kuralları" icon={Shield}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Otomatik Bildirim</p>
|
||||
<p className="text-xs text-dark-400">Hata anında otomatik bildir</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.autoNotify}
|
||||
onChange={(v) => updateSettings('notifications.autoNotify', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Ping Hatası</p>
|
||||
<p className="text-xs text-dark-400">Ping başarısız olduğunda</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.notifyOnPingFail}
|
||||
onChange={(v) => updateSettings('notifications.notifyOnPingFail', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">404 Hatası</p>
|
||||
<p className="text-xs text-dark-400">HTTP 404 alındığında</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.notifyOn404}
|
||||
onChange={(v) => updateSettings('notifications.notifyOn404', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Bağlantı Hatası</p>
|
||||
<p className="text-xs text-dark-400">ERR_CONNRESET gibi hatalar</p>
|
||||
</div>
|
||||
<Toggle
|
||||
enabled={localSettings.notifications.notifyOnConnReset}
|
||||
onChange={(v) => updateSettings('notifications.notifyOnConnReset', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* App Info */}
|
||||
<SettingsSection title="Uygulama Bilgisi" icon={Info}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-dark-100">Versiyon</p>
|
||||
<p className="text-xs text-dark-400">IP Monitor</p>
|
||||
</div>
|
||||
<span className="text-accent-blue font-mono">v{appInfo?.version || '1.0.0'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-dark-400" />
|
||||
<div>
|
||||
<p className="text-dark-100">Çalışma Modu</p>
|
||||
<p className="text-xs text-dark-400">Ayarların saklandığı konum</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
appInfo?.isPortable
|
||||
? 'bg-accent-green/20 text-accent-green'
|
||||
: 'bg-accent-blue/20 text-accent-blue'
|
||||
}`}>
|
||||
{appInfo?.isPortable ? 'Portable' : 'Normal'}
|
||||
</span>
|
||||
</div>
|
||||
{appInfo?.dataPath && (
|
||||
<div className="bg-dark-700/50 rounded-lg p-3">
|
||||
<p className="text-xs text-dark-400 mb-1">Veri Konumu:</p>
|
||||
<p className="text-xs text-dark-300 font-mono break-all">{appInfo.dataPath}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dark-500 mt-2">
|
||||
<p>💡 Portable mod için exe yanına <code className="bg-dark-700 px-1 rounded">portable.txt</code> dosyası oluşturun veya <code className="bg-dark-700 px-1 rounded">data</code> klasörü açın.</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { create } from 'zustand'
|
||||
import type { Channel, IpInfo, ErrorLogEntry, AppSettings, MonitoringResult } from '@shared/types'
|
||||
|
||||
interface AppState {
|
||||
// IP Info
|
||||
ipInfo: IpInfo | null
|
||||
ipLoading: boolean
|
||||
setIpInfo: (info: IpInfo | null) => void
|
||||
setIpLoading: (loading: boolean) => void
|
||||
|
||||
// Channels
|
||||
channels: Channel[]
|
||||
channelsLoading: boolean
|
||||
setChannels: (channels: Channel[]) => void
|
||||
updateChannel: (channel: Channel) => void
|
||||
setChannelsLoading: (loading: boolean) => void
|
||||
|
||||
// Monitoring
|
||||
isMonitoring: boolean
|
||||
monitoringResults: Map<string, MonitoringResult>
|
||||
setIsMonitoring: (running: boolean) => void
|
||||
updateMonitoringResult: (result: MonitoringResult) => void
|
||||
clearMonitoringResults: () => void
|
||||
|
||||
// Error Log
|
||||
errorLog: ErrorLogEntry[]
|
||||
addErrorLog: (entry: ErrorLogEntry) => void
|
||||
setErrorLog: (log: ErrorLogEntry[]) => void
|
||||
clearErrorLog: () => void
|
||||
|
||||
// Settings
|
||||
settings: AppSettings | null
|
||||
setSettings: (settings: AppSettings) => void
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
general: {
|
||||
startMinimized: false,
|
||||
minimizeToTray: true,
|
||||
autoStart: false,
|
||||
language: 'tr',
|
||||
},
|
||||
monitoring: {
|
||||
pingInterval: 30,
|
||||
streamCheckInterval: 60,
|
||||
timeout: 5000,
|
||||
retryCount: 2,
|
||||
},
|
||||
notifications: {
|
||||
enabled: true,
|
||||
windows: { enabled: true },
|
||||
telegram: {
|
||||
enabled: false,
|
||||
botToken: '',
|
||||
chatId: '',
|
||||
},
|
||||
email: {
|
||||
enabled: false,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpUser: '',
|
||||
smtpPass: '',
|
||||
fromEmail: '',
|
||||
toEmail: '',
|
||||
},
|
||||
autoNotify: true,
|
||||
notifyOnPingFail: true,
|
||||
notifyOnStreamError: true,
|
||||
notifyOn404: true,
|
||||
notifyOnConnReset: true,
|
||||
},
|
||||
panel: {
|
||||
apiUrl: '',
|
||||
authToken: '',
|
||||
monitorId: '',
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set) => ({
|
||||
// IP Info
|
||||
ipInfo: null,
|
||||
ipLoading: false,
|
||||
setIpInfo: (info) => set({ ipInfo: info }),
|
||||
setIpLoading: (loading) => set({ ipLoading: loading }),
|
||||
|
||||
// Channels
|
||||
channels: [],
|
||||
channelsLoading: false,
|
||||
setChannels: (channels) => set({ channels }),
|
||||
updateChannel: (channel) =>
|
||||
set((state) => ({
|
||||
channels: state.channels.map((c) =>
|
||||
c.id === channel.id ? channel : c
|
||||
),
|
||||
})),
|
||||
setChannelsLoading: (loading) => set({ channelsLoading: loading }),
|
||||
|
||||
// Monitoring
|
||||
isMonitoring: false,
|
||||
monitoringResults: new Map(),
|
||||
setIsMonitoring: (running) => set({ isMonitoring: running }),
|
||||
updateMonitoringResult: (result) =>
|
||||
set((state) => {
|
||||
const newResults = new Map(state.monitoringResults)
|
||||
newResults.set(result.channelId, result)
|
||||
return { monitoringResults: newResults }
|
||||
}),
|
||||
clearMonitoringResults: () => set({ monitoringResults: new Map() }),
|
||||
|
||||
// Error Log
|
||||
errorLog: [],
|
||||
addErrorLog: (entry) =>
|
||||
set((state) => ({
|
||||
errorLog: [entry, ...state.errorLog].slice(0, 100), // Keep last 100
|
||||
})),
|
||||
setErrorLog: (log) => set({ errorLog: log }),
|
||||
clearErrorLog: () => set({ errorLog: [] }),
|
||||
|
||||
// Settings
|
||||
settings: defaultSettings,
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
}))
|
||||
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg-primary: #0f0f12;
|
||||
--color-bg-secondary: #18181b;
|
||||
--color-bg-tertiary: #1e1e22;
|
||||
--color-text-primary: #e4e4e7;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-accent: #61afef;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Title bar drag region */
|
||||
.titlebar-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Glassmorphism effect */
|
||||
.glass {
|
||||
background: rgba(30, 30, 34, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Status dot animation */
|
||||
@keyframes pulse-online {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.status-online {
|
||||
animation: pulse-online 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #61afef 0%, #c678dd 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Input focus styles */
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(97, 175, 239, 0.2);
|
||||
}
|
||||
|
||||
/* Button active state */
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Slide in from left */
|
||||
@keyframes slideInLeft {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideInLeft 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
export interface ElectronAPI {
|
||||
// Window controls
|
||||
minimize: () => void
|
||||
maximize: () => void
|
||||
close: () => void
|
||||
|
||||
// IP Service
|
||||
getIpInfo: () => Promise<import('@shared/types').IpInfo>
|
||||
|
||||
// Channel Management
|
||||
parseM3u8: (url: string) => Promise<import('@shared/types').Channel[]>
|
||||
getChannels: () => Promise<import('@shared/types').Channel[]>
|
||||
updateChannel: (channel: import('@shared/types').Channel) => Promise<void>
|
||||
setChannelMonitored: (channelId: string, monitored: boolean) => Promise<void>
|
||||
|
||||
// Monitoring
|
||||
startMonitoring: () => Promise<void>
|
||||
stopMonitoring: () => Promise<void>
|
||||
getMonitoringStatus: () => Promise<{ isRunning: boolean }>
|
||||
onMonitoringUpdate: (callback: (result: import('@shared/types').MonitoringResult) => void) => () => void
|
||||
|
||||
// Ping
|
||||
pingHost: (host: string) => Promise<import('@shared/types').PingResult>
|
||||
|
||||
// Notifications
|
||||
sendNotification: (title: string, body: string, type?: import('@shared/types').NotificationType) => Promise<void>
|
||||
testNotification: (type: import('@shared/types').NotificationType) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Panel API
|
||||
panelRegister: (config: import('@shared/types').PanelConfig) => Promise<{ success: boolean; authToken?: string }>
|
||||
panelGetIps: () => Promise<import('@shared/types').PanelIpPool>
|
||||
panelSendVote: (vote: import('@shared/types').BurnVote) => Promise<{ success: boolean }>
|
||||
|
||||
// Settings
|
||||
getSettings: () => Promise<import('@shared/types').AppSettings>
|
||||
saveSettings: (settings: import('@shared/types').AppSettings) => Promise<void>
|
||||
|
||||
// Error Log
|
||||
getErrorLog: () => Promise<import('@shared/types').ErrorLogEntry[]>
|
||||
clearErrorLog: () => Promise<void>
|
||||
|
||||
// App Info
|
||||
getAppInfo: () => Promise<{ version: string; isPortable: boolean; dataPath: string }>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
// Channel Types
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
group?: string;
|
||||
logo?: string;
|
||||
isMonitored: boolean;
|
||||
status: ChannelStatus;
|
||||
lastCheck?: number;
|
||||
lastError?: string;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
export type ChannelStatus = 'online' | 'offline' | 'error' | 'unknown' | 'checking';
|
||||
|
||||
// IP Info Types
|
||||
export interface IpInfo {
|
||||
ip: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
city?: string;
|
||||
isp?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
// Monitoring Types
|
||||
export interface MonitoringResult {
|
||||
channelId: string;
|
||||
timestamp: number;
|
||||
status: ChannelStatus;
|
||||
latency?: number;
|
||||
httpStatus?: number;
|
||||
error?: string;
|
||||
errorType?: 'TIMEOUT' | 'CONNRESET' | 'CONNREFUSED' | 'HTTP_ERROR' | 'PARSE_ERROR' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
host: string;
|
||||
alive: boolean;
|
||||
time?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Notification Types
|
||||
export type NotificationType = 'windows' | 'telegram' | 'email';
|
||||
|
||||
export interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
windows: {
|
||||
enabled: boolean;
|
||||
};
|
||||
telegram: {
|
||||
enabled: boolean;
|
||||
botToken: string;
|
||||
chatId: string;
|
||||
};
|
||||
email: {
|
||||
enabled: boolean;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPass: string;
|
||||
fromEmail: string;
|
||||
toEmail: string;
|
||||
};
|
||||
autoNotify: boolean;
|
||||
notifyOnPingFail: boolean;
|
||||
notifyOnStreamError: boolean;
|
||||
notifyOn404: boolean;
|
||||
notifyOnConnReset: boolean;
|
||||
}
|
||||
|
||||
// Panel API Types (from MONITOR_API_GUIDE)
|
||||
export interface PanelConfig {
|
||||
apiUrl: string;
|
||||
authToken: string;
|
||||
monitorId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PanelIpInfo {
|
||||
ip_address: string;
|
||||
server_id: string;
|
||||
check_priority: 'high' | 'normal' | 'low';
|
||||
}
|
||||
|
||||
export interface PanelIpPool {
|
||||
active: PanelIpInfo[];
|
||||
honeypot: PanelIpInfo[];
|
||||
}
|
||||
|
||||
export interface BurnVote {
|
||||
ip_address: string;
|
||||
vote: 'burn' | 'ok' | 'abstain';
|
||||
reason: string;
|
||||
evidence: {
|
||||
check_type: string;
|
||||
port?: number;
|
||||
response_time_ms?: number;
|
||||
error_message?: string;
|
||||
timestamp: number;
|
||||
monitor_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
// App Settings
|
||||
export interface AppSettings {
|
||||
general: {
|
||||
startMinimized: boolean;
|
||||
minimizeToTray: boolean;
|
||||
autoStart: boolean;
|
||||
language: 'tr' | 'en';
|
||||
};
|
||||
monitoring: {
|
||||
pingInterval: number; // seconds
|
||||
streamCheckInterval: number; // seconds
|
||||
timeout: number; // ms
|
||||
retryCount: number;
|
||||
};
|
||||
notifications: NotificationSettings;
|
||||
panel: PanelConfig;
|
||||
}
|
||||
|
||||
// IPC Channel Names
|
||||
export const IPC_CHANNELS = {
|
||||
// IP Service
|
||||
GET_IP_INFO: 'ip:get-info',
|
||||
|
||||
// Channel Management
|
||||
PARSE_M3U8: 'channel:parse-m3u8',
|
||||
GET_CHANNELS: 'channel:get-all',
|
||||
UPDATE_CHANNEL: 'channel:update',
|
||||
SET_MONITORED: 'channel:set-monitored',
|
||||
|
||||
// Monitoring
|
||||
START_MONITORING: 'monitor:start',
|
||||
STOP_MONITORING: 'monitor:stop',
|
||||
GET_MONITORING_STATUS: 'monitor:status',
|
||||
MONITORING_UPDATE: 'monitor:update',
|
||||
|
||||
// Ping
|
||||
PING_HOST: 'ping:host',
|
||||
|
||||
// Notifications
|
||||
SEND_NOTIFICATION: 'notify:send',
|
||||
TEST_NOTIFICATION: 'notify:test',
|
||||
|
||||
// Panel API
|
||||
PANEL_REGISTER: 'panel:register',
|
||||
PANEL_GET_IPS: 'panel:get-ips',
|
||||
PANEL_SEND_VOTE: 'panel:send-vote',
|
||||
|
||||
// Settings
|
||||
GET_SETTINGS: 'settings:get',
|
||||
SAVE_SETTINGS: 'settings:save',
|
||||
|
||||
// Window
|
||||
MINIMIZE_TO_TRAY: 'window:minimize-tray',
|
||||
} as const;
|
||||
|
||||
// Error Log Entry
|
||||
export interface ErrorLogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
errorType: string;
|
||||
errorMessage: string;
|
||||
httpStatus?: number;
|
||||
notified: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/renderer/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Custom dark theme inspired by One Dark
|
||||
dark: {
|
||||
50: '#e4e4e7',
|
||||
100: '#c8c8cd',
|
||||
200: '#a1a1aa',
|
||||
300: '#71717a',
|
||||
400: '#52525b',
|
||||
500: '#3f3f46',
|
||||
600: '#27272a',
|
||||
700: '#1e1e22',
|
||||
800: '#18181b',
|
||||
900: '#0f0f12',
|
||||
950: '#09090b',
|
||||
},
|
||||
accent: {
|
||||
blue: '#61afef',
|
||||
purple: '#c678dd',
|
||||
green: '#98c379',
|
||||
red: '#e06c75',
|
||||
orange: '#d19a66',
|
||||
cyan: '#56b6c2',
|
||||
yellow: '#e5c07b',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||
},
|
||||
keyframes: {
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(97, 175, 239, 0.5)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(97, 175, 239, 0.8)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/renderer/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/renderer/**/*", "src/shared/**/*", "src/vite-env.d.ts"]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist/main",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
root: '.',
|
||||
build: {
|
||||
outDir: 'dist/renderer',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src/renderer'),
|
||||
'@shared': path.resolve(__dirname, './src/shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue