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:
alper 2025-12-01 00:44:04 +03:00
commit adb574e68e
37 changed files with 16354 additions and 0 deletions

34
.gitignore vendored Normal file
View File

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

92
README.md Normal file
View File

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

31
electron-builder.json Normal file
View File

@ -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
}

17
index.html Normal file
View File

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

11189
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@ -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"
}
}

7
postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

353
src/main/index.ts Normal file
View File

@ -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()
})

57
src/main/preload.ts Normal file
View File

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

View File

@ -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>
`
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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 }
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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))
}
}

View File

@ -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, '\\$&')
}
}

204
src/main/store.ts Normal file
View File

@ -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 }

27
src/renderer/App.tsx Normal file
View File

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

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

11
src/renderer/main.tsx Normal file
View File

@ -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>,
)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 ı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ü ın.</p>
</div>
</div>
</SettingsSection>
</div>
</div>
)
}

View File

@ -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 })),
}))

View File

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

51
src/renderer/types/electron.d.ts vendored Normal file
View File

@ -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
}
}

173
src/shared/types.ts Normal file
View File

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

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />

52
tailwind.config.js Normal file
View File

@ -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: [],
}

26
tsconfig.json Normal file
View File

@ -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"]
}

24
tsconfig.main.json Normal file
View File

@ -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"]
}

24
vite.config.ts Normal file
View File

@ -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,
},
})