first commit
commit
c70e91599d
@ -0,0 +1,6 @@
|
||||
/venv
|
||||
.env
|
||||
create_log_table.sql
|
||||
# Ignore Python bytecode files
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@ -0,0 +1,12 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
@ -0,0 +1,129 @@
|
||||
# Telegram Integration for Digital Twin Monitoring
|
||||
|
||||
This document provides comprehensive information about the Telegram integration for the Digital Twin Monitoring application. The integration allows the application to send automatic notifications to Telegram when parts with red status are detected.
|
||||
|
||||
## Overview
|
||||
|
||||
The Telegram integration consists of:
|
||||
|
||||
1. **telegram_notifier.py**: A module that provides functionality to send Telegram notifications
|
||||
2. **test_telegram_notification.py**: A script to test the Telegram notification functionality
|
||||
3. **telegram_setup_guide.md**: A guide explaining how to set up a Telegram bot and configure the integration
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic notifications when parts with red status are detected
|
||||
- Detailed information about the affected parts in the notification message
|
||||
- Configurable via environment variables or direct code configuration
|
||||
- Easy to test and verify using the provided test script
|
||||
|
||||
## Files Added
|
||||
|
||||
### 1. telegram_notifier.py
|
||||
|
||||
This is the core module that provides the Telegram notification functionality. It includes:
|
||||
|
||||
- `TelegramNotifier` class: A class to send notifications to Telegram
|
||||
- `check_red_status_and_notify` function: A convenience function to check for parts with red status and send notifications
|
||||
|
||||
### 2. test_telegram_notification.py
|
||||
|
||||
A script to test the Telegram notification functionality. It includes:
|
||||
|
||||
- `test_direct_message` function: Tests sending a direct message to Telegram
|
||||
- `test_red_status_notification` function: Tests sending a notification for parts with red status
|
||||
|
||||
### 3. telegram_setup_guide.md
|
||||
|
||||
A comprehensive guide explaining how to set up a Telegram bot and configure the integration.
|
||||
|
||||
## Integration with app.py
|
||||
|
||||
The Telegram notification functionality is integrated into the main application (app.py) in the `/api/data` route. When this route is called, the application:
|
||||
|
||||
1. Fetches data from the database
|
||||
2. Analyzes the data to determine part statuses
|
||||
3. Checks for parts with red status and sends a Telegram notification if any are found
|
||||
4. Returns the results as JSON
|
||||
|
||||
## How to Use
|
||||
|
||||
### 1. Setup
|
||||
|
||||
Follow the instructions in `telegram_setup_guide.md` to:
|
||||
|
||||
1. Create a Telegram bot
|
||||
2. Get your chat ID
|
||||
3. Configure the application with your bot token and chat ID
|
||||
|
||||
### 2. Testing
|
||||
|
||||
Use the `test_telegram_notification.py` script to test your Telegram notification setup:
|
||||
|
||||
```bash
|
||||
python test_telegram_notification.py
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Send a direct test message to your Telegram chat
|
||||
2. Send a notification for sample parts with red status
|
||||
|
||||
### 3. Running the Application
|
||||
|
||||
Once you've set up and tested the Telegram integration, you can run the application as usual:
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
The application will now automatically send Telegram notifications when parts with red status are detected.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these environment variables to configure the Telegram integration:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`: Your Telegram bot token
|
||||
- `TELEGRAM_CHAT_ID`: Your Telegram chat ID
|
||||
|
||||
### Direct Configuration
|
||||
|
||||
You can also configure the Telegram integration directly in code:
|
||||
|
||||
```python
|
||||
from telegram_notifier import check_red_status_and_notify
|
||||
|
||||
# After analyzing data and getting results
|
||||
check_red_status_and_notify(results, bot_token="your_bot_token_here", chat_id="your_chat_id_here")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you're not receiving notifications:
|
||||
|
||||
1. Check that your bot token and chat ID are correct
|
||||
2. Ensure your bot has permission to send messages to the chat
|
||||
3. Check the application logs for any error messages related to Telegram notifications
|
||||
4. Make sure the requests library is installed (`pip install requests`)
|
||||
5. Verify that there are actually parts with red status in the system
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never hardcode your bot token in the source code
|
||||
- Use environment variables or a secure configuration system
|
||||
- Regularly rotate your bot token if you suspect it has been compromised
|
||||
- Be careful about what information is included in the notifications
|
||||
|
||||
## Dependencies
|
||||
|
||||
The Telegram integration requires the `requests` library, which has been added to `requirements.txt`. Make sure to install it:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Telegram Bot API Documentation](https://core.telegram.org/bots/api)
|
||||
- [Python Requests Library Documentation](https://docs.python-requests.org/en/latest/)
|
||||
@ -0,0 +1,434 @@
|
||||
import os
|
||||
import psycopg2
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from telegram_notifier import check_red_status_and_notify
|
||||
import traceback
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Database configuration
|
||||
DB_HOST_1 = "192.168.1.85" # For tables: pf_parts, ms_equipment_master
|
||||
DB_HOST_2 = "192.168.1.86" # For table: dl_pi_fetch_last
|
||||
DB_PORT = "5432"
|
||||
DB_USER = "postgres"
|
||||
DB_PASS = "postgres"
|
||||
DB_NAME = "digital_twin"
|
||||
|
||||
def chunked_iterable(iterable, size):
|
||||
"""Helper to split list into chunks"""
|
||||
for i in range(0, len(iterable), size):
|
||||
yield iterable[i:i + size]
|
||||
|
||||
def get_db_connection(table=None, retries=3, delay=2):
|
||||
"""Koneksi PostgreSQL dengan retry dan timeout"""
|
||||
host = DB_HOST_2 if table == "dl_pi_fetch_last" else DB_HOST_1
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return psycopg2.connect(
|
||||
host=host,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
dbname=DB_NAME,
|
||||
connect_timeout=5 # timeout agar tidak hang lama
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"DB connection error (attempt {attempt + 1}/{retries}): {e}")
|
||||
if attempt < retries - 1:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
raise
|
||||
|
||||
def fetch_part_info():
|
||||
"""Fetch part information from pf_parts and ms_equipment_master tables"""
|
||||
try:
|
||||
conn = get_db_connection("pf_parts") # Using DB_HOST_1 (192.168.1.85)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Query to fetch part information with equipment grouping
|
||||
query = """
|
||||
SELECT p.id as part_id, p.location_tag, p.part_name, e.id as equipment_id, e.name as equipment_name
|
||||
FROM pf_parts p
|
||||
LEFT JOIN ms_equipment_master e ON p.equipment_id = e.id
|
||||
ORDER BY e.id, p.id
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Convert to DataFrame for easier processing
|
||||
df = pd.DataFrame(rows, columns=['part_id', 'location_tag', 'part_name', 'equipment_id', 'equipment_name'])
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return df
|
||||
except Exception as e:
|
||||
print(f"Database error fetching part info: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def fetch_data(days=3):
|
||||
"""Fetch data from dl_pi_fetch_last table for the last 'days' days"""
|
||||
try:
|
||||
conn = get_db_connection("dl_pi_fetch_last") # Using DB_HOST_2 (192.168.1.86)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Calculate the date range
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query to fetch data for the last 'days' days
|
||||
query = """
|
||||
SELECT part_id, value, created_at
|
||||
FROM dl_pi_fetch_last
|
||||
WHERE created_at >= %s AND created_at <= %s
|
||||
ORDER BY part_id, created_at
|
||||
"""
|
||||
|
||||
cursor.execute(query, (start_date, end_date))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Convert to DataFrame for easier analysis
|
||||
df = pd.DataFrame(rows, columns=['part_id', 'value', 'created_at'])
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return df
|
||||
except Exception as e:
|
||||
print(f"Database error: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def analyze_data(df, part_info_df):
|
||||
"""
|
||||
Analyze data based on the three criteria:
|
||||
1. Same value for 3 consecutive days (error)
|
||||
2. Value is 0 or null (error)
|
||||
3. Significant increase/decrease compared to previous 3 days (warning)
|
||||
|
||||
Also adds part information (label, equipment_id, equipment_name) to the results
|
||||
"""
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
# Group by part_id
|
||||
for part_id, group in df.groupby('part_id'):
|
||||
# Sort by timestamp
|
||||
group = group.sort_values('created_at')
|
||||
|
||||
# Initialize status
|
||||
status = "green"
|
||||
messages = []
|
||||
|
||||
# Check for null or zero values (Criterion 2)
|
||||
if group['value'].isnull().any() or (group['value'] == 0).any():
|
||||
status = "red"
|
||||
messages.append("Error: Contains null or zero values")
|
||||
|
||||
# Check for same value for 3 consecutive days (Criterion 1)
|
||||
# Group by date and check for consecutive identical values
|
||||
group['date'] = group['created_at'].dt.date
|
||||
daily_values = group.groupby('date')['value'].mean().reset_index()
|
||||
|
||||
if len(daily_values) >= 3:
|
||||
for i in range(len(daily_values) - 2):
|
||||
if (daily_values['value'].iloc[i] == daily_values['value'].iloc[i+1] == daily_values['value'].iloc[i+2]):
|
||||
status = "red"
|
||||
messages.append(f"Error: Same value ({daily_values['value'].iloc[i]}) for 3 consecutive days")
|
||||
break
|
||||
|
||||
# Check for significant changes (Criterion 3)
|
||||
if len(daily_values) >= 4:
|
||||
for i in range(3, len(daily_values)):
|
||||
current_value = daily_values['value'].iloc[i]
|
||||
prev_values = daily_values['value'].iloc[i-3:i]
|
||||
avg_prev = prev_values.mean()
|
||||
|
||||
# Define threshold for significant change (e.g., 30%)
|
||||
threshold = 0.3
|
||||
|
||||
if avg_prev != 0: # Avoid division by zero
|
||||
change_ratio = abs((current_value - avg_prev) / avg_prev)
|
||||
|
||||
if change_ratio > threshold:
|
||||
if status != "red": # Don't override error status
|
||||
status = "yellow"
|
||||
messages.append(f"Warning: Significant change detected ({change_ratio:.2%} change from previous average)")
|
||||
|
||||
# Get part information
|
||||
part_info = part_info_df[part_info_df['part_id'] == part_id]
|
||||
|
||||
# Create label and get equipment information
|
||||
label = ""
|
||||
equipment_id = None
|
||||
equipment_name = None
|
||||
|
||||
if not part_info.empty:
|
||||
location_tag = part_info['location_tag'].iloc[0] if not part_info['location_tag'].isnull().iloc[0] else ""
|
||||
part_name = part_info['part_name'].iloc[0] if not part_info['part_name'].isnull().iloc[0] else ""
|
||||
label = f"{location_tag} | {part_name}".strip()
|
||||
equipment_id = part_info['equipment_id'].iloc[0] if not part_info['equipment_id'].isnull().iloc[0] else None
|
||||
equipment_name = part_info['equipment_name'].iloc[0] if not part_info['equipment_name'].isnull().iloc[0] else None
|
||||
|
||||
# Add to results
|
||||
results.append({
|
||||
'part_id': part_id,
|
||||
'label': label,
|
||||
'equipment_id': equipment_id,
|
||||
'equipment_name': equipment_name,
|
||||
'status': status,
|
||||
'messages': messages,
|
||||
'latest_value': group['value'].iloc[-1] if not group.empty else None,
|
||||
'latest_created_at': group['created_at'].iloc[-1] if not group.empty else None
|
||||
})
|
||||
|
||||
# Sort results by equipment_id, then part_id
|
||||
results = sorted(results, key=lambda x: (x['equipment_id'] or "", x['part_id']))
|
||||
|
||||
return results
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Render the main monitoring dashboard"""
|
||||
return render_template('index.html')
|
||||
|
||||
def fetch_part_history(part_id, days=14):
|
||||
"""Fetch historical data for a specific part_id for the last 'days' days"""
|
||||
try:
|
||||
# First connection for dl_pi_fetch_last table (DB_HOST_2)
|
||||
conn_data = get_db_connection("dl_pi_fetch_last")
|
||||
cursor_data = conn_data.cursor()
|
||||
|
||||
# Calculate the date range
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query to fetch data for the specific part_id for the last 'days' days
|
||||
query = """
|
||||
SELECT part_id, value, created_at
|
||||
FROM dl_pi_fetch_last
|
||||
WHERE part_id = %s AND created_at >= %s AND created_at <= %s
|
||||
ORDER BY created_at
|
||||
"""
|
||||
|
||||
cursor_data.execute(query, (part_id, start_date, end_date))
|
||||
rows = cursor_data.fetchall()
|
||||
|
||||
# Second connection for pf_parts and ms_equipment_master tables (DB_HOST_1)
|
||||
conn_parts = get_db_connection("pf_parts")
|
||||
cursor_parts = conn_parts.cursor()
|
||||
|
||||
# Fetch part information
|
||||
part_info_query = """
|
||||
SELECT p.id as part_id, p.location_tag, p.part_name, e.id as equipment_id, e.name as equipment_name
|
||||
FROM pf_parts p
|
||||
LEFT JOIN ms_equipment_master e ON p.equipment_id = e.id
|
||||
WHERE p.id = %s
|
||||
"""
|
||||
|
||||
cursor_parts.execute(part_info_query, (part_id,))
|
||||
part_info_row = cursor_parts.fetchone()
|
||||
|
||||
# Create part info dictionary
|
||||
part_info = {
|
||||
'part_id': part_id,
|
||||
'location_tag': part_info_row[1] if part_info_row and part_info_row[1] else "",
|
||||
'part_name': part_info_row[2] if part_info_row and part_info_row[2] else "",
|
||||
'equipment_id': part_info_row[3] if part_info_row and part_info_row[3] else None,
|
||||
'equipment_name': part_info_row[4] if part_info_row and part_info_row[4] else None
|
||||
}
|
||||
|
||||
# Create label
|
||||
label = f"{part_info['location_tag']} {part_info['part_name']}".strip()
|
||||
|
||||
# Convert to list of dictionaries for JSON serialization
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
'part_id': row[0],
|
||||
'label': label,
|
||||
'value': row[1],
|
||||
'created_at': row[2].isoformat() if row[2] else None
|
||||
})
|
||||
|
||||
# Close both connections
|
||||
cursor_data.close()
|
||||
conn_data.close()
|
||||
cursor_parts.close()
|
||||
conn_parts.close()
|
||||
|
||||
return {
|
||||
'part_info': part_info,
|
||||
'label': label,
|
||||
'history': result
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Database error: {e}")
|
||||
return {
|
||||
'part_info': {'part_id': part_id},
|
||||
'label': part_id,
|
||||
'history': []
|
||||
}
|
||||
|
||||
@app.route('/api/data')
|
||||
def get_data():
|
||||
"""API endpoint to fetch data from _log_pi_status_data table with date filter"""
|
||||
# Get date parameter from request, default to today's date
|
||||
filter_date_str = request.args.get('date')
|
||||
|
||||
try:
|
||||
if filter_date_str:
|
||||
# Parse the date string from frontend (format: YYYY-MM-DD)
|
||||
filter_date = datetime.strptime(filter_date_str, '%Y-%m-%d').date()
|
||||
else:
|
||||
# Default to today's date
|
||||
filter_date = datetime.now().date()
|
||||
|
||||
# Connect to database
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Query to fetch data from _log_pi_status_data for the specified date
|
||||
query = """
|
||||
SELECT timestamp, part_id, label, equipment_id, equipment_name, status,
|
||||
messages, latest_value, latest_created_at
|
||||
FROM _log_pi_status_data
|
||||
WHERE DATE(timestamp) = %s
|
||||
ORDER BY equipment_id, part_id
|
||||
"""
|
||||
|
||||
cursor.execute(query, (filter_date,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Process the results
|
||||
results = []
|
||||
for row in rows:
|
||||
timestamp, part_id, label, equipment_id, equipment_name, status, messages_str, latest_value, latest_created_at = row
|
||||
|
||||
# Convert messages string back to list
|
||||
messages = messages_str.split('; ') if messages_str else []
|
||||
|
||||
results.append({
|
||||
'part_id': part_id,
|
||||
'label': label,
|
||||
'equipment_id': equipment_id,
|
||||
'equipment_name': equipment_name,
|
||||
'status': status,
|
||||
'messages': messages,
|
||||
'latest_value': latest_value,
|
||||
'latest_created_at': latest_created_at
|
||||
})
|
||||
|
||||
# Close database connection
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# Group results by equipment_id for frontend display
|
||||
grouped_results = {}
|
||||
for item in results:
|
||||
equipment_id = item['equipment_id'] or 'unknown'
|
||||
equipment_name = item['equipment_name'] or 'Unknown Equipment'
|
||||
|
||||
if equipment_id not in grouped_results:
|
||||
grouped_results[equipment_id] = {
|
||||
'equipment_id': equipment_id,
|
||||
'equipment_name': equipment_name,
|
||||
'parts': []
|
||||
}
|
||||
|
||||
grouped_results[equipment_id]['parts'].append(item)
|
||||
|
||||
# Convert to list for JSON serialization
|
||||
final_results = list(grouped_results.values())
|
||||
|
||||
return jsonify(final_results)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in get_data: {e}")
|
||||
return jsonify([]), 500
|
||||
|
||||
|
||||
@app.route('/check-data')
|
||||
def checkdata():
|
||||
"""API endpoint to fetch and analyze data"""
|
||||
df = fetch_data(days=10) # Fetch data for the last 3 days
|
||||
part_info_df = fetch_part_info() # Fetch part information
|
||||
results = analyze_data(df, part_info_df)
|
||||
|
||||
# Telegram notification jika ada status merah
|
||||
base_url = request.host_url.rstrip('/')
|
||||
check_red_status_and_notify(results, "8201929832:AAFhDu7LD4xbNyDQ9Cc2JSuTDMhqrLaDDdc", "-1002721738007", base_url)
|
||||
|
||||
# Logging ke database
|
||||
conn = None
|
||||
cursor = None
|
||||
try:
|
||||
conn = get_db_connection() # koneksi ke 192.168.1.85
|
||||
cursor = conn.cursor()
|
||||
current_timestamp = datetime.now()
|
||||
|
||||
data_to_insert = []
|
||||
for result in results:
|
||||
# Only insert records with status other than "green"
|
||||
if result['status'] != "green":
|
||||
messages_str = '; '.join(result['messages']) if result['messages'] else None
|
||||
latest_created_at = result['latest_created_at']
|
||||
data_to_insert.append((
|
||||
current_timestamp,
|
||||
result['part_id'],
|
||||
result['label'],
|
||||
result['equipment_id'],
|
||||
result['equipment_name'],
|
||||
result['status'],
|
||||
messages_str,
|
||||
result['latest_value'],
|
||||
latest_created_at
|
||||
))
|
||||
|
||||
query = """
|
||||
INSERT INTO _log_pi_status_data
|
||||
(timestamp, part_id, label, equipment_id, equipment_name, status, messages, latest_value, \
|
||||
latest_created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) \
|
||||
"""
|
||||
|
||||
batch_size = 500
|
||||
total = 0
|
||||
for batch in chunked_iterable(data_to_insert, batch_size):
|
||||
cursor.executemany(query, batch)
|
||||
conn.commit()
|
||||
total += len(batch)
|
||||
|
||||
print(f"Successfully logged {total} results to _log_pi_status_data table")
|
||||
except Exception as e:
|
||||
print(f"Error logging results to database: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if cursor: cursor.close()
|
||||
if conn: conn.close()
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
@app.route('/api/part-history')
|
||||
def get_part_history():
|
||||
"""API endpoint to fetch historical data for a specific part_id"""
|
||||
part_id = request.args.get('part_id')
|
||||
if not part_id:
|
||||
return jsonify({'error': 'part_id parameter is required'}), 400
|
||||
|
||||
days = request.args.get('days', 14, type=int)
|
||||
result = fetch_part_history(part_id, days)
|
||||
return jsonify(result)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', debug=True)
|
||||
@ -0,0 +1,7 @@
|
||||
Flask==2.3.3
|
||||
psycopg2-binary==2.9.9
|
||||
python-dotenv==1.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
pandas==2.1.1
|
||||
numpy==1.26.0
|
||||
requests==2.31.0
|
||||
@ -0,0 +1,308 @@
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background-color: #145C6B;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 4px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #D6D5D5;
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.red { background-color: #E60012; }
|
||||
.yellow { background-color: #F1E436; }
|
||||
.green { background-color: #E1F3F6; }
|
||||
|
||||
.message-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.message-list li {
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.part-id-link {
|
||||
color: #145C6B;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.part-id-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-counts-container {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-count .status-indicator {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
#red-status-count, #yellow-status-count {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Custom checkbox styling */
|
||||
.custom-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.custom-checkbox .form-check-input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ced4da;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.custom-checkbox .form-check-input:checked {
|
||||
background-color: #145C6B;
|
||||
border-color: #145C6B;
|
||||
}
|
||||
|
||||
.custom-checkbox .form-check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status-filter-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 10px;
|
||||
background-color: #E1F3F6;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #D6D5D5;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.text-filter-container {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-filter-container .form-control {
|
||||
height: 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 992px) {
|
||||
.card-body, .card-header {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-body, .card-header {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.form-check.form-switch {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.date-filter-container {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status-filter-container {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
flex-direction: row;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-filter-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.message-list li {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-body, .card-header {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.message-list li {
|
||||
font-size: 10px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.part-id-link {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#red-status-count, #yellow-status-count {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
td[colspan="5"] {
|
||||
background-color: #E1F3F6;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Bootstrap overrides for new color scheme */
|
||||
.bg-primary {
|
||||
background-color: #145C6B !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #145C6B !important;
|
||||
border-color: #145C6B !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
|
||||
background-color: #0e4a57 !important;
|
||||
border-color: #0e4a57 !important;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #1CA3B7 !important;
|
||||
border-color: #1CA3B7 !important;
|
||||
}
|
||||
|
||||
.btn-secondary:hover, .btn-secondary:focus, .btn-secondary:active {
|
||||
background-color: #178a9b !important;
|
||||
border-color: #178a9b !important;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #E1F3F6 !important;
|
||||
border-color: #D6D5D5 !important;
|
||||
color: #145C6B !important;
|
||||
}
|
||||
|
||||
.bg-light {
|
||||
background-color: #D6D5D5 !important;
|
||||
}
|
||||
@ -0,0 +1,405 @@
|
||||
// Global variables to store the original data and filter states
|
||||
let originalData = [];
|
||||
let historyChart = null;
|
||||
let selectedDate = null;
|
||||
let showRedOnly = false;
|
||||
let showYellowOnly = false;
|
||||
let textFilterValue = '';
|
||||
|
||||
// Wait for the DOM to be fully loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set default date to today
|
||||
const today = new Date();
|
||||
const formattedDate = today.toISOString().split('T')[0]; // Format: YYYY-MM-DD
|
||||
document.getElementById('dateFilter').value = formattedDate;
|
||||
selectedDate = formattedDate;
|
||||
|
||||
// Initial data load
|
||||
fetchData(selectedDate);
|
||||
|
||||
// Update the last updated time
|
||||
updateLastUpdated();
|
||||
|
||||
// Even though we have meta refresh, we'll also set up a JavaScript timer
|
||||
// as a backup and to update the "last updated" time without a full page refresh
|
||||
setInterval(function() {
|
||||
updateLastUpdated();
|
||||
}, 500000); // Update the time every 5 seconds
|
||||
|
||||
|
||||
// Add event listener for the date filter button
|
||||
document.getElementById('applyDateFilter').addEventListener('click', function() {
|
||||
const dateInput = document.getElementById('dateFilter');
|
||||
selectedDate = dateInput.value;
|
||||
fetchData(selectedDate);
|
||||
});
|
||||
|
||||
// Add event listeners for status filter checkboxes
|
||||
document.getElementById('redStatusFilter').addEventListener('change', function() {
|
||||
showRedOnly = this.checked;
|
||||
if (showRedOnly) {
|
||||
// If red is checked, uncheck yellow
|
||||
document.getElementById('yellowStatusFilter').checked = false;
|
||||
showYellowOnly = false;
|
||||
}
|
||||
// Update the table with the current filter settings
|
||||
updateTable(originalData);
|
||||
});
|
||||
|
||||
document.getElementById('yellowStatusFilter').addEventListener('change', function() {
|
||||
showYellowOnly = this.checked;
|
||||
if (showYellowOnly) {
|
||||
// If yellow is checked, uncheck red
|
||||
document.getElementById('redStatusFilter').checked = false;
|
||||
showRedOnly = false;
|
||||
}
|
||||
// Update the table with the current filter settings
|
||||
updateTable(originalData);
|
||||
});
|
||||
|
||||
// Add event listener for text filter input
|
||||
document.getElementById('textFilter').addEventListener('input', function() {
|
||||
textFilterValue = this.value.toLowerCase().trim();
|
||||
// Update the table with the current filter settings
|
||||
updateTable(originalData);
|
||||
});
|
||||
|
||||
// Initialize the chart modal event
|
||||
const chartModal = document.getElementById('chartModal');
|
||||
chartModal.addEventListener('hidden.bs.modal', function () {
|
||||
// Destroy the chart when the modal is closed to prevent memory leaks
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
historyChart = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch monitoring data from the API
|
||||
* @param {string} date - The date to filter data (format: YYYY-MM-DD)
|
||||
*/
|
||||
function fetchData(date) {
|
||||
const loadingMessage = document.getElementById('loading-message');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
// Show loading message
|
||||
loadingMessage.classList.remove('d-none');
|
||||
errorMessage.classList.add('d-none');
|
||||
|
||||
// Build the URL with date parameter if provided
|
||||
let url = '/api/data';
|
||||
if (date) {
|
||||
url += `?date=${encodeURIComponent(date)}`;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Hide loading message
|
||||
loadingMessage.classList.add('d-none');
|
||||
|
||||
// Store the original data
|
||||
originalData = data;
|
||||
|
||||
// Update the table with the data
|
||||
updateTable(data);
|
||||
|
||||
// Update last updated time
|
||||
updateLastUpdated();
|
||||
})
|
||||
.catch(error => {
|
||||
// Hide loading message and show error message
|
||||
loadingMessage.classList.add('d-none');
|
||||
errorMessage.classList.remove('d-none');
|
||||
console.error('Error fetching data:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the monitoring data with equipment groups
|
||||
*/
|
||||
function updateTable(data) {
|
||||
const equipmentGroupsContainer = document.getElementById('equipment-groups');
|
||||
|
||||
// Clear existing content
|
||||
equipmentGroupsContainer.innerHTML = '';
|
||||
|
||||
if (data.length === 0) {
|
||||
equipmentGroupsContainer.innerHTML = `<div class="alert alert-info">No monitoring data available</div>`;
|
||||
updateStatusCounts(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const tableResponsive = document.createElement('div');
|
||||
tableResponsive.className = 'table-responsive';
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'table table-striped table-hover';
|
||||
|
||||
const tableHeader = document.createElement('thead');
|
||||
tableHeader.innerHTML = `
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Status</th>
|
||||
<th>Latest Value</th>
|
||||
<th>Latest Timestamp</th>
|
||||
<th>Messages</th>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const tableBody = document.createElement('tbody');
|
||||
let totalParts = 0;
|
||||
let redStatusCount = 0;
|
||||
let yellowStatusCount = 0;
|
||||
|
||||
data.forEach(equipment => {
|
||||
// Filter parts based on status filter checkboxes and text filter
|
||||
let filteredParts = equipment.parts;
|
||||
|
||||
if (showRedOnly) {
|
||||
filteredParts = filteredParts.filter(part => part.status === 'red');
|
||||
} else if (showYellowOnly) {
|
||||
filteredParts = filteredParts.filter(part => part.status === 'yellow');
|
||||
}
|
||||
|
||||
// Apply text filter if there's any text in the input
|
||||
if (textFilterValue) {
|
||||
filteredParts = filteredParts.filter(part => {
|
||||
// Check if any of the part's text content matches the filter
|
||||
const label = part.label ? part.label.toLowerCase() : '';
|
||||
const value = part.latest_value !== null ? String(part.latest_value).toLowerCase() : '';
|
||||
const messages = part.messages ? part.messages.join(' ').toLowerCase() : '';
|
||||
|
||||
return label.includes(textFilterValue) ||
|
||||
value.includes(textFilterValue) ||
|
||||
messages.includes(textFilterValue) ||
|
||||
equipment.equipment_name.toLowerCase().includes(textFilterValue);
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredParts.length === 0) return;
|
||||
|
||||
// Insert equipment name as a row
|
||||
const equipmentRow = document.createElement('tr');
|
||||
equipmentRow.innerHTML = `
|
||||
<td colspan="5" class="fw-bold bg-light">${equipment.equipment_name}</td>
|
||||
`;
|
||||
tableBody.appendChild(equipmentRow);
|
||||
|
||||
// Insert part rows
|
||||
filteredParts.forEach(part => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Count red and yellow statuses
|
||||
if (part.status === 'red') {
|
||||
redStatusCount++;
|
||||
} else if (part.status === 'yellow') {
|
||||
yellowStatusCount++;
|
||||
}
|
||||
|
||||
let messagesHtml = '';
|
||||
if (part.messages && part.messages.length > 0) {
|
||||
messagesHtml = '<ul class="message-list">';
|
||||
part.messages.forEach(message => {
|
||||
messagesHtml += `<li>${message}</li>`;
|
||||
});
|
||||
messagesHtml += '</ul>';
|
||||
} else {
|
||||
messagesHtml = '<span class="text-muted">No issues</span>';
|
||||
}
|
||||
|
||||
const timestamp = part.latest_created_at
|
||||
? new Date(part.latest_created_at).toLocaleString()
|
||||
: 'N/A';
|
||||
|
||||
row.innerHTML = `
|
||||
<td><a href="#" class="part-id-link" data-part-id="${part.part_id}">${part.label}</a></td>
|
||||
<td class="text-center">
|
||||
<div class="status-cell">
|
||||
<span class="status-indicator ${part.status} me-2"></span>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
<td>${part.latest_value !== null ? part.latest_value : 'N/A'}</td>
|
||||
<td>${timestamp}</td>
|
||||
<td>${messagesHtml}</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
totalParts++;
|
||||
});
|
||||
});
|
||||
|
||||
if (totalParts === 0) {
|
||||
equipmentGroupsContainer.innerHTML = `<div class="alert alert-info">No data matching the current filter</div>`;
|
||||
updateStatusCounts(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
table.appendChild(tableHeader);
|
||||
table.appendChild(tableBody);
|
||||
// Create responsive scroll container
|
||||
const scrollContainer = document.createElement('div');
|
||||
scrollContainer.className = 'overflow-auto';
|
||||
|
||||
// Set different max heights based on screen size
|
||||
const setResponsiveHeight = () => {
|
||||
if (window.innerWidth <= 576) {
|
||||
// Mobile phones
|
||||
scrollContainer.style.maxHeight = 'calc(100vh - 400px)';
|
||||
} else if (window.innerWidth <= 768) {
|
||||
// Tablets
|
||||
scrollContainer.style.maxHeight = 'calc(100vh - 200px)';
|
||||
} else {
|
||||
// Desktops
|
||||
scrollContainer.style.maxHeight = 'calc(100vh - 200px)';
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial height
|
||||
setResponsiveHeight();
|
||||
|
||||
// Update height on window resize
|
||||
window.addEventListener('resize', setResponsiveHeight);
|
||||
|
||||
scrollContainer.style.border = '1px solid #dee2e6';
|
||||
scrollContainer.appendChild(table);
|
||||
|
||||
tableResponsive.appendChild(scrollContainer);
|
||||
equipmentGroupsContainer.appendChild(tableResponsive);
|
||||
|
||||
// Update the status counts display
|
||||
updateStatusCounts(redStatusCount, yellowStatusCount);
|
||||
|
||||
|
||||
// Setup chart links
|
||||
document.querySelectorAll('.part-id-link').forEach(link => {
|
||||
link.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const partId = this.getAttribute('data-part-id');
|
||||
showPartHistoryChart(partId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the last updated time
|
||||
*/
|
||||
function updateLastUpdated() {
|
||||
const lastUpdatedElement = document.getElementById('last-updated');
|
||||
const now = new Date();
|
||||
// lastUpdatedElement.textContent = now.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status counts display
|
||||
* @param {number} redCount - Number of red status items
|
||||
* @param {number} yellowCount - Number of yellow status items
|
||||
*/
|
||||
function updateStatusCounts(redCount, yellowCount) {
|
||||
const redStatusCountElement = document.getElementById('red-status-count');
|
||||
const yellowStatusCountElement = document.getElementById('yellow-status-count');
|
||||
|
||||
redStatusCountElement.textContent = redCount;
|
||||
yellowStatusCountElement.textContent = yellowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch historical data for a specific part_id
|
||||
*/
|
||||
function fetchPartHistory(partId) {
|
||||
return fetch(`/api/part-history?part_id=${encodeURIComponent(partId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching part history:', error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the chart for a specific part_id
|
||||
*/
|
||||
function showPartHistoryChart(partId) {
|
||||
// Show the modal
|
||||
const chartModal = new bootstrap.Modal(document.getElementById('chartModal'));
|
||||
chartModal.show();
|
||||
|
||||
// Fetch the historical data
|
||||
fetchPartHistory(partId).then(result => {
|
||||
// Update the modal title with part label if available
|
||||
const label = result.label || partId;
|
||||
document.getElementById('modal-part-id').textContent = `${partId} (${label})`;
|
||||
|
||||
const historyData = result.history || [];
|
||||
|
||||
if (historyData.length === 0) {
|
||||
// Handle empty data
|
||||
document.getElementById('historyChart').innerHTML = 'No historical data available for this part.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the data for the chart
|
||||
const chartLabels = [];
|
||||
const chartValues = [];
|
||||
|
||||
historyData.forEach(item => {
|
||||
// Format the date for display
|
||||
const date = new Date(item.created_at);
|
||||
chartLabels.push(date.toLocaleString());
|
||||
chartValues.push(item.value);
|
||||
});
|
||||
|
||||
// Create the chart
|
||||
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
}
|
||||
|
||||
// Create new chart
|
||||
historyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: [{
|
||||
label: `Values for ${label}`,
|
||||
data: chartValues,
|
||||
borderColor: '#1CA3B7',
|
||||
tension: 0.1,
|
||||
fill: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date and Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
class TelegramNotifier:
|
||||
"""
|
||||
A class to send notifications to Telegram when parts with red status are detected.
|
||||
|
||||
To use this class:
|
||||
1. Create a Telegram bot using BotFather and get the API token
|
||||
2. Get the chat ID where messages should be sent
|
||||
3. Set environment variables TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID
|
||||
4. Or pass these values directly to the constructor
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token=None, chat_id=None):
|
||||
"""
|
||||
Initialize the TelegramNotifier with bot token and chat ID.
|
||||
|
||||
Args:
|
||||
bot_token (str, optional): Telegram Bot API token. If None, reads from TELEGRAM_BOT_TOKEN env var.
|
||||
chat_id (str, optional): Telegram chat ID to send messages to. If None, reads from TELEGRAM_CHAT_ID env var.
|
||||
"""
|
||||
# Use provided parameters first, fall back to environment variables if not provided
|
||||
self.bot_token = bot_token if bot_token is not None else os.environ.get('TELEGRAM_BOT_TOKEN')
|
||||
self.chat_id = chat_id if chat_id is not None else os.environ.get('TELEGRAM_CHAT_ID')
|
||||
|
||||
if not self.bot_token:
|
||||
print("Warning: Telegram bot token not provided. Notifications will not be sent.")
|
||||
|
||||
if not self.chat_id:
|
||||
print("Warning: Telegram chat ID not provided. Notifications will not be sent.")
|
||||
|
||||
def send_message(self, message):
|
||||
"""
|
||||
Send a message to the Telegram chat.
|
||||
|
||||
Args:
|
||||
message (str): The message to send
|
||||
|
||||
Returns:
|
||||
bool: True if message was sent successfully, False otherwise
|
||||
"""
|
||||
if not self.bot_token or not self.chat_id:
|
||||
print("Cannot send Telegram message: bot token or chat ID is missing")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error sending Telegram message: {e}")
|
||||
return False
|
||||
|
||||
def check_and_notify(self, results, base_url="http://localhost:5000"):
|
||||
"""
|
||||
Check for parts with red status and send a notification if any are found.
|
||||
|
||||
Args:
|
||||
results (list): List of dictionaries containing equipment and part information.
|
||||
Each dictionary should have 'equipment_id', 'equipment_name', 'label',
|
||||
'latest_created_at', 'latest_value', 'messages', 'part_id', and 'status' keys.
|
||||
base_url (str, optional): Base URL for the link to more detailed data
|
||||
|
||||
Returns:
|
||||
bool: True if notification was sent, False otherwise
|
||||
"""
|
||||
# Check if results is a list of dictionaries with 'parts' key (old format)
|
||||
# The old format had a nested structure where each equipment had a 'parts' key
|
||||
# containing a list of part dictionaries
|
||||
if results and isinstance(results, list) and 'parts' in results[0]:
|
||||
# Extract all parts from the nested structure (backward compatibility)
|
||||
all_parts = []
|
||||
for equipment in results:
|
||||
if 'parts' in equipment:
|
||||
all_parts.extend(equipment['parts'])
|
||||
else:
|
||||
# Use the flat list directly (new format)
|
||||
# The new format is a flat list of dictionaries, each containing information about a part
|
||||
# with fields like equipment_id, equipment_name, label, latest_created_at, latest_value,
|
||||
# messages, part_id, and status
|
||||
all_parts = results
|
||||
|
||||
# Find parts with different status colors
|
||||
red_parts = [part for part in all_parts if part['status'] == 'red']
|
||||
yellow_parts = [part for part in all_parts if part['status'] == 'yellow']
|
||||
green_parts = [part for part in all_parts if part['status'] == 'green']
|
||||
|
||||
if not red_parts:
|
||||
print("No parts with red status found. No notification needed.")
|
||||
return False
|
||||
|
||||
# Create notification message with the required format
|
||||
message = "Telah terjadi anomali data di PI yang diidentifikasi, dengan contoh data sebagai berikut:\n\n"
|
||||
|
||||
# Add only the top 3 parts with red status
|
||||
for i, part in enumerate(red_parts[:3], 1):
|
||||
part_label = part.get('label', f"Part ID: {part['part_id']}")
|
||||
message += f"{i}. {part_label}\n"
|
||||
|
||||
# If there are more than 3 parts with red status, add an ellipsis
|
||||
if len(red_parts) > 3:
|
||||
message += "4. ...\n"
|
||||
|
||||
# Add count of status colors as a list
|
||||
message += f"\nJumlah status:\n"
|
||||
message += f"- Merah: {len(red_parts)}\n"
|
||||
message += f"- Kuning: {len(yellow_parts)}\n"
|
||||
message += f"- Hijau: {len(green_parts)}\n"
|
||||
|
||||
# Add link to more detailed data
|
||||
message += f"\nKlik disini untuk data lebih lengkap:\n"
|
||||
message += f"<a href=\"{base_url}/\">Klik Disini</a>\n\n"
|
||||
|
||||
# Add signature
|
||||
message += "Salam,\nAdmin Digital Twin"
|
||||
|
||||
# Send the notification
|
||||
return self.send_message(message)
|
||||
|
||||
|
||||
def check_red_status_and_notify(results, bot_token=None, chat_id=None, base_url="http://localhost:5000"):
|
||||
"""
|
||||
Convenience function to check for red status parts and send a notification.
|
||||
|
||||
Args:
|
||||
results (list): List of dictionaries containing equipment and part information.
|
||||
Each dictionary should have 'equipment_id', 'equipment_name', 'label',
|
||||
'latest_created_at', 'latest_value', 'messages', 'part_id', and 'status' keys.
|
||||
Also supports the old format with nested 'parts' key for backward compatibility.
|
||||
bot_token (str, optional): Telegram Bot API token
|
||||
chat_id (str, optional): Telegram chat ID to send messages to
|
||||
base_url (str, optional): Base URL for the link to more detailed data
|
||||
|
||||
Returns:
|
||||
bool: True if notification was sent, False otherwise
|
||||
"""
|
||||
notifier = TelegramNotifier(bot_token, chat_id)
|
||||
return notifier.check_and_notify(results, base_url)
|
||||
|
||||
|
||||
def kirimpesan(results, bot_token=None, chat_id=None, base_url="http://localhost:5000"):
|
||||
"""
|
||||
Convenience function to check for red status parts and send a notification.
|
||||
|
||||
Args:
|
||||
results (list): List of dictionaries containing equipment and part information.
|
||||
Each dictionary should have 'equipment_id', 'equipment_name', 'label',
|
||||
'latest_created_at', 'latest_value', 'messages', 'part_id', and 'status' keys.
|
||||
Also supports the old format with nested 'parts' key for backward compatibility.
|
||||
bot_token (str, optional): Telegram Bot API token
|
||||
chat_id (str, optional): Telegram chat ID to send messages to
|
||||
base_url (str, optional): Base URL for the link to more detailed data
|
||||
|
||||
Returns:
|
||||
bool: True if notification was sent, False otherwise
|
||||
"""
|
||||
notifier = TelegramNotifier(bot_token, chat_id)
|
||||
return notifier.check_and_notify(results, base_url)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage with the new flat list data structure
|
||||
example_results = [
|
||||
{
|
||||
"equipment_id": "0096ab37-14b0-4946-b7e3-76160f501f3e",
|
||||
"equipment_name": "MBFP MOTOR B",
|
||||
"label": "3FW-TE324 | MBFP MTR B BRG 1 TEMP",
|
||||
"latest_created_at": "Tue, 29 Jul 2025 20:00:00 GMT",
|
||||
"latest_value": 35.8,
|
||||
"messages": [],
|
||||
"part_id": "15ac1ec9-6681-48a8-a59b-a0ff8a681ddf",
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"equipment_id": "0096ab37-14b0-4946-b7e3-76160f501f3e",
|
||||
"equipment_name": "MBFP MOTOR B",
|
||||
"label": "3FW-TE325 | MBFP MTR B BRG 2 TEMP",
|
||||
"latest_created_at": "Tue, 29 Jul 2025 20:00:00 GMT",
|
||||
"latest_value": 33.4,
|
||||
"messages": [],
|
||||
"part_id": "529a0f2e-65e2-4cb3-aedb-cbd0678fbcee",
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"equipment_id": "0096ab37-14b0-4946-b7e3-76160f501f3e",
|
||||
"equipment_name": "MBFP MOTOR B",
|
||||
"label": "3FW-P300-A2 | MBFP MTR B CUR",
|
||||
"latest_created_at": "Tue, 29 Jul 2025 20:00:00 GMT",
|
||||
"latest_value": 0.0,
|
||||
"messages": [
|
||||
"Error: Contains null or zero values",
|
||||
"Error: Same value (0.0) for 3 consecutive days"
|
||||
],
|
||||
"part_id": "5be40d7a-ad0b-4e31-b6ab-d755004efef7",
|
||||
"status": "red"
|
||||
},
|
||||
{
|
||||
"equipment_id": "0096ab37-14b0-4946-b7e3-76160f501f3e",
|
||||
"equipment_name": "MBFP MOTOR B",
|
||||
"label": "3FW-TE320 | MBFP MTR B WDG TEMP W",
|
||||
"latest_created_at": "Tue, 29 Jul 2025 20:00:00 GMT",
|
||||
"latest_value": 34.81954,
|
||||
"messages": [],
|
||||
"part_id": "9726b4c9-a7f0-4adf-b493-06bd2973dc62",
|
||||
"status": "green"
|
||||
},
|
||||
{
|
||||
"equipment_id": "0096ab37-14b0-4946-b7e3-76160f501f3e",
|
||||
"equipment_name": "MBFP MOTOR B",
|
||||
"label": "3FW-TE316 | MBFP MTR B WDG TEMP U",
|
||||
"latest_created_at": "Tue, 29 Jul 2025 20:00:00 GMT",
|
||||
"latest_value": 35.4,
|
||||
"messages": [],
|
||||
"part_id": "a148a27d-1ef4-4301-9fbe-a22def4e491c",
|
||||
"status": "green"
|
||||
}
|
||||
]
|
||||
|
||||
# Test the notification with a sample base_url
|
||||
base_url = "http://example.com"
|
||||
check_red_status_and_notify(example_results, base_url=base_url)
|
||||
@ -0,0 +1,103 @@
|
||||
# Telegram Notification Setup Guide
|
||||
|
||||
This guide explains how to set up Telegram notifications for the Digital Twin Monitoring application. The application will send alerts to a Telegram chat when parts with red status are detected.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Telegram account
|
||||
- Internet connection
|
||||
- Access to the Digital Twin Monitoring application
|
||||
|
||||
## Step 1: Create a Telegram Bot
|
||||
|
||||
1. Open Telegram and search for "BotFather" (@BotFather)
|
||||
2. Start a chat with BotFather
|
||||
3. Send the command `/newbot`
|
||||
4. Follow the instructions to create a new bot:
|
||||
- Provide a name for your bot (e.g., "Digital Twin Monitor")
|
||||
- Provide a username for your bot (must end with "bot", e.g., "digital_twin_monitor_bot")
|
||||
5. Once created, BotFather will provide you with a token (API key) that looks like this:
|
||||
```
|
||||
123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
|
||||
```
|
||||
-> 8201929832:AAFhDu7LD4xbNyDQ9Cc2JSuTDMhqrLaDDdc
|
||||
6. **Important**: Keep this token secure and do not share it publicly
|
||||
|
||||
## Step 2: Get Your Chat ID
|
||||
|
||||
There are several ways to get your Telegram chat ID:
|
||||
|
||||
### Method 1: Using the "userinfobot"
|
||||
|
||||
1. Search for "userinfobot" (@userinfobot) in Telegram
|
||||
2. Start a chat with this bot
|
||||
3. The bot will reply with your chat ID (a number like `123456789`)
|
||||
|
||||
### Method 2: For Group Chats
|
||||
|
||||
If you want to send notifications to a group:
|
||||
|
||||
1. Add your bot to the group
|
||||
2. Send a message in the group
|
||||
3. Visit this URL in your browser (replace `YOUR_BOT_TOKEN` with your actual bot token):
|
||||
```
|
||||
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
|
||||
```
|
||||
4. Look for the "chat" object in the JSON response, which contains the "id" field
|
||||
5. This is your group chat ID (usually a negative number for groups)
|
||||
-> -1002721738007
|
||||
## Step 3: Configure the Application
|
||||
|
||||
There are two ways to configure the Telegram notification feature:
|
||||
|
||||
### Method 1: Environment Variables (Recommended)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
```
|
||||
|
||||
You can set these variables in your operating system, or create a `.env` file in the project root directory with these values.
|
||||
|
||||
### Method 2: Direct Configuration in Code
|
||||
|
||||
You can also pass the bot token and chat ID directly when calling the notification function:
|
||||
|
||||
```python
|
||||
from telegram_notifier import check_red_status_and_notify
|
||||
|
||||
# After analyzing data and getting results
|
||||
check_red_status_and_notify(results, bot_token="your_bot_token_here", chat_id="your_chat_id_here")
|
||||
```
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
To test if your Telegram notification setup is working:
|
||||
|
||||
1. Make sure the application is running
|
||||
2. Access the `/api/data` endpoint or wait for the regular data check
|
||||
3. If any parts have red status, you should receive a notification in your Telegram chat
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you're not receiving notifications:
|
||||
|
||||
1. Check that your bot token and chat ID are correct
|
||||
2. Ensure your bot has permission to send messages to the chat
|
||||
3. Check the application logs for any error messages related to Telegram notifications
|
||||
4. Make sure the requests library is installed (`pip install requests`)
|
||||
5. Verify that there are actually parts with red status in the system
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never hardcode your bot token in the source code
|
||||
- Use environment variables or a secure configuration system
|
||||
- Regularly rotate your bot token if you suspect it has been compromised
|
||||
- Be careful about what information is included in the notifications
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Telegram Bot API Documentation](https://core.telegram.org/bots/api)
|
||||
- [Python Requests Library Documentation](https://docs.python-requests.org/en/latest/)
|
||||
@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Digital Twin Monitoring Dashboard</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<meta http-equiv="refresh" content="3600">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid container-lg mt-2 mb-2 px-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fs-6 fs-md-5">Part Monitoring</h5>
|
||||
<span class="badge bg-light text-dark d-none d-sm-inline">Digital Twin</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info" id="loading-message">
|
||||
Loading data, please wait...
|
||||
</div>
|
||||
<div class="alert alert-danger d-none" id="error-message">
|
||||
Error loading data. Please check the server connection.
|
||||
</div>
|
||||
|
||||
<div class="filter-controls d-flex flex-wrap align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex flex-column flex-md-row align-items-start">
|
||||
<div class="status-filter-container me-md-3 mb-2 mb-md-0 w-100 w-md-auto">
|
||||
<div class="checkbox-container">
|
||||
<div class="form-check custom-checkbox me-3">
|
||||
<input class="form-check-input" type="checkbox" id="redStatusFilter">
|
||||
<label class="form-check-label" for="redStatusFilter">
|
||||
<span class="status-indicator red me-1"></span>
|
||||
Show Red Only
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check custom-checkbox">
|
||||
<input class="form-check-input" type="checkbox" id="yellowStatusFilter">
|
||||
<label class="form-check-label" for="yellowStatusFilter">
|
||||
<span class="status-indicator yellow me-1"></span>
|
||||
Show Yellow Only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-filter-container">
|
||||
<input type="text" class="form-control" id="textFilter" placeholder="Filter table...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="date-filter-container w-100 w-md-auto">
|
||||
<div class="d-flex flex-column flex-sm-row align-items-start align-items-sm-center">
|
||||
<div class="date-filter me-sm-2 mb-2 mb-sm-0 w-100 w-sm-auto">
|
||||
<label for="dateFilter" class="form-label mb-0">Filter by date:</label>
|
||||
<input type="date" class="form-control" id="dateFilter">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="applyDateFilter">Apply Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-counts-container d-flex align-items-center mt-2 mt-md-0">
|
||||
<div class="status-count red-count me-3">
|
||||
<span class="status-indicator red me-1"></span>
|
||||
<span id="red-status-count">0</span>
|
||||
</div>
|
||||
<div class="status-count yellow-count">
|
||||
<span class="status-indicator yellow me-1"></span>
|
||||
<span id="yellow-status-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="equipment-groups">
|
||||
<!-- Equipment groups will be loaded here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4" style="display: none;">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Monitoring Criteria</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="status-indicator red me-2"></span>
|
||||
<strong>Error:</strong> Same value for 3 consecutive days
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="status-indicator red me-2"></span>
|
||||
<strong>Error:</strong> Value is 0 or null
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="status-indicator yellow me-2"></span>
|
||||
<strong>Warning:</strong> Significant increase/decrease compared to previous 3 days
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="status-indicator green me-2"></span>
|
||||
<strong>Normal:</strong> No issues detected
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Modal -->
|
||||
<div class="modal fade" id="chartModal" tabindex="-1" aria-labelledby="chartModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen-sm-down modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fs-6 fs-md-5" id="chartModalLabel">Historical Data for Part ID: <span id="modal-part-id"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="chart-container" style="position: relative; height:50vh; width:100%">
|
||||
<canvas id="historyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue