Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions hookwise/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging
import os
import secrets
import uuid
from typing import Any, cast

Expand All @@ -18,10 +17,13 @@ def create_app() -> Flask:
app = Flask(__name__, template_folder="../templates", static_folder="../static")
secret_key = os.environ.get("SECRET_KEY")
if not secret_key:
secret_key = secrets.token_hex(32)
_logger.critical(
"SECRET_KEY not set! Sessions will be invalidated on restart. Set SECRET_KEY in your environment."
)
if os.environ.get("DEBUG_MODE", "false").lower() == "true":
secret_key = "dev-secret-key"
_logger.warning("SECRET_KEY not set, using default for development.")
else:
_logger.critical("SECRET_KEY must be set in production!")
raise RuntimeError("SECRET_KEY env var is required")

app.config["SECRET_KEY"] = secret_key
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
"DATABASE_URL", "postgresql://hookwise:hookwise_pass@postgres:5432/hookwise"
Expand Down
40 changes: 18 additions & 22 deletions hookwise/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from .routes import main_bp

# --- History & Logs ---

@main_bp.route("/api/activity/history")
@auth_required
def get_activity_history() -> Any:
Expand All @@ -41,7 +41,7 @@
# This mimics the log_to_web calls in tasks.py
message = log.error_message or "Processed"
level = "info"

if log.status == "failed":
message = log.error_message or "Unknown error"
level = "error"
Expand All @@ -66,44 +66,40 @@
message = f"Closed ticket (ID: {log.ticket_id})"
level = "success"
# Removed the dead 'skipped' action branch as it's handled by log.status

payload_data = {"raw": log.payload}
if log.payload and log.payload.startswith(("{", "[")):
try:
payload_data = json.loads(log.payload)
except (json.JSONDecodeError, TypeError):
pass

history.append({
"timestamp": log.created_at.isoformat(),
"message": message,
"level": level,
"config_name": log.config.name if log.config else "System",
"payload": payload_data,
"ticket_id": log.ticket_id
})
history.append(
{
"timestamp": log.created_at.isoformat(),
"message": message,
"level": level,
"config_name": log.config.name if log.config else "System",
"payload": payload_data,
"ticket_id": log.ticket_id,
}
)
return jsonify(history)

@main_bp.route("/api/activity/trigger-timeout-check", methods=["POST"])
@auth_required
def trigger_timeout_check() -> Any:
from .tasks import check_webhook_timeouts

try:
# Trigger the task in the background
task = check_webhook_timeouts.delay()
return jsonify({
"status": "success",
"message": "Manual timeout check triggered in background.",
"task_id": task.id
})
return jsonify(
{"status": "success", "message": "Manual timeout check triggered in background.", "task_id": task.id}
)
except Exception as e:
current_app.logger.error(f"Failed to enqueue timeout check: {e}")
return jsonify({
"status": "error",
"message": "Failed to enqueue timeout check",
"details": str(e)
}), 503

return jsonify({"status": "error", "message": "Failed to enqueue timeout check", "details": str(e)}), 503

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

@main_bp.route("/history")
@auth_required
Expand Down Expand Up @@ -141,7 +137,7 @@
from datetime import datetime, timedelta

query = query.filter(WebhookLog.created_at <= datetime.fromisoformat(date_to) + timedelta(days=1))

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
pagination = query.order_by(WebhookLog.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
Expand Down
4 changes: 4 additions & 0 deletions hookwise/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@

logger = logging.getLogger(__name__)


class ConnectWiseError(Exception):
pass


class TicketNotFoundError(ConnectWiseError):
pass


class TicketRequestError(ConnectWiseError):
pass


class ConnectWiseClient:
def __init__(self) -> None:
self.base_url: str = os.getenv("CW_URL", "https://api-na.myconnectwise.net/v4_6_release/apis/3.0")
Expand Down
1 change: 1 addition & 0 deletions hookwise/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def build_redis_uri(password, host, port, db=0):
return f"redis://:{quoted_password}@{host}:{port}/{db}"
return f"redis://{host}:{port}/{db}"


_redis_password = os.environ.get("REDIS_PASSWORD")
_redis_host = os.environ.get("REDIS_HOST", "localhost")
_redis_port = os.environ.get("REDIS_PORT", 6379)
Expand Down
6 changes: 3 additions & 3 deletions hookwise/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,13 @@ def index() -> Any:
if last_activity:
if last_activity.tzinfo is None:
last_activity = last_activity.replace(tzinfo=timezone.utc)

# Calculation: Next stale is either last_seen + timeout OR last_alert + timeout
# We show whichever is further in the future
timeout_delta = timedelta(hours=config.timeout_hours or 24)

next_alert_from_seen = last_activity + timeout_delta

if config.last_stale_alert_at:
last_alert = config.last_stale_alert_at
if last_alert.tzinfo is None:
Expand Down
96 changes: 45 additions & 51 deletions hookwise/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,7 @@ def check_webhook_timeouts() -> None:

if not config.timeout_ticket_id:
# No ticket open yet (or was closed), create one
summary = (
f"[TIMEOUT] Webhook Endpoint: {config.name} - "
f"No data for {config.timeout_hours}h"
)
summary = f"[TIMEOUT] Webhook Endpoint: {config.name} - No data for {config.timeout_hours}h"
description = (
f"The webhook endpoint '{config.name}' has not received any data for over "
f"{config.timeout_hours} hours.\n"
Expand All @@ -307,8 +304,7 @@ def check_webhook_timeouts() -> None:
config.last_stale_alert_at = now
updates += 1
logger.warning(
f"Created timeout ticket #{config.timeout_ticket_id} "
f"for endpoint '{config.name}'"
f"Created timeout ticket #{config.timeout_ticket_id} for endpoint '{config.name}'"
)
log_msg = (
f"Timeout alert: Created ticket #{config.timeout_ticket_id} "
Expand All @@ -319,22 +315,21 @@ def check_webhook_timeouts() -> None:
"timeout_alert",
config.id,
f"Created timeout ticket #{config.timeout_ticket_id}",
commit=False
commit=False,
)

req_id = f"timeout-{int(time.time())}"
log_entry = WebhookLog(
config_id=config.id,
request_id=req_id,
payload=json.dumps({
"alert": "stale_endpoint",
"timeout_hours": config.timeout_hours
}),
payload=json.dumps(
{"alert": "stale_endpoint", "timeout_hours": config.timeout_hours}
),
status="processed",
action="create",
ticket_id=config.timeout_ticket_id,
source_ip="system",
error_message=f"Created timeout ticket #{config.timeout_ticket_id}"
error_message=f"Created timeout ticket #{config.timeout_ticket_id}",
)
db.session.add(log_entry)
db.session.commit()
Expand All @@ -343,27 +338,26 @@ def check_webhook_timeouts() -> None:
log_to_web(
f"Timeout alert failure: Could not create ticket for {config.name}",
"error",
config.name
config.name,
)
log_audit(
"timeout_error",
config.id,
"Failed to create timeout ticket in CW API",
commit=False
commit=False,
)

req_id = f"timeout-err-{int(time.time())}"
log_entry = WebhookLog(
config_id=config.id,
request_id=req_id,
payload=json.dumps({
"alert": "stale_endpoint",
"timeout_hours": config.timeout_hours
}),
payload=json.dumps(
{"alert": "stale_endpoint", "timeout_hours": config.timeout_hours}
),
status="failed",
action="create",
source_ip="system",
error_message="Failed to create ticket in ConnectWise API."
error_message="Failed to create ticket in ConnectWise API.",
)
db.session.add(log_entry)
db.session.commit()
Expand All @@ -387,29 +381,31 @@ def check_webhook_timeouts() -> None:
log_to_web(
f"Timeout alert repeated: Added note to ticket #{config.timeout_ticket_id}",
"warning",
config.name
config.name,
)

log_entry = WebhookLog(
config_id=config.id,
request_id=req_id,
payload=json.dumps({
"alert": "stale_endpoint_repeat",
"timeout_hours": config.timeout_hours,
"timeout_ticket_id": config.timeout_ticket_id,
"last_stale_alert_at": (
config.last_stale_alert_at.isoformat()
if config.last_stale_alert_at
else None
),
"created_at": config.created_at.isoformat(),
"hours_stale": round(hours_since_activity, 2)
}),
payload=json.dumps(
{
"alert": "stale_endpoint_repeat",
"timeout_hours": config.timeout_hours,
"timeout_ticket_id": config.timeout_ticket_id,
"last_stale_alert_at": (
config.last_stale_alert_at.isoformat()
if config.last_stale_alert_at
else None
),
"created_at": config.created_at.isoformat(),
"hours_stale": round(hours_since_activity, 2),
}
),
status="processed",
action="update",
ticket_id=config.timeout_ticket_id,
source_ip="system",
error_message=f"Added repeat alert note to ticket #{config.timeout_ticket_id}"
error_message=f"Added repeat alert note to ticket #{config.timeout_ticket_id}",
)
db.session.add(log_entry)
db.session.commit()
Expand All @@ -421,20 +417,22 @@ def check_webhook_timeouts() -> None:
log_entry = WebhookLog(
config_id=config.id,
request_id=req_id,
payload=json.dumps({
"alert": "stale_endpoint_repeat_failed",
"timeout_hours": config.timeout_hours,
"timeout_ticket_id": config.timeout_ticket_id,
"hours_stale": round(hours_since_activity, 2)
}),
payload=json.dumps(
{
"alert": "stale_endpoint_repeat_failed",
"timeout_hours": config.timeout_hours,
"timeout_ticket_id": config.timeout_ticket_id,
"hours_stale": round(hours_since_activity, 2),
}
),
status="failed",
action="update",
ticket_id=config.timeout_ticket_id,
source_ip="system",
error_message=(
f"ConnectWise API failed to add repeat alert note to ticket "
f"#{config.timeout_ticket_id}"
)
),
)
db.session.add(log_entry)
db.session.commit()
Expand Down Expand Up @@ -555,21 +553,19 @@ def _resolve_timeout_alert(config: WebhookConfig) -> None:
if config.timeout_ticket_id:
ticket_id = config.timeout_ticket_id
resolution = f"Webhook data received again for endpoint '{config.name}'. Automatically closing timeout alert."

try:
if cw_client.close_ticket(ticket_id, resolution, status_name=config.close_status):
logger.info(f"Closed timeout ticket #{ticket_id} for endpoint '{config.name}'")
log_to_web(f"Timeout alert resolved: Closed ticket #{ticket_id}", "success", config.name)

import time

from .models import WebhookLog
from .utils import log_audit

log_audit(
"timeout_resolve",
config.id,
f"Automatically closed timeout ticket #{ticket_id}",
commit=False
"timeout_resolve", config.id, f"Automatically closed timeout ticket #{ticket_id}", commit=False
)
log_entry = WebhookLog(
config_id=config.id,
Expand All @@ -578,14 +574,12 @@ def _resolve_timeout_alert(config: WebhookConfig) -> None:
status="processed",
action="close",
ticket_id=ticket_id,
source_ip="system"
source_ip="system",
)
db.session.add(log_entry)
config.timeout_ticket_id = None
else:
logger.warning(
f"Failed to close timeout ticket #{ticket_id} for endpoint '{config.name}'. "
)
logger.warning(f"Failed to close timeout ticket #{ticket_id} for endpoint '{config.name}'. ")
except TicketNotFoundError:
logger.warning(
f"Timeout ticket #{ticket_id} for endpoint '{config.name}' "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [c["name"] for c in inspector.get_columns("webhook_config")]

if "last_stale_alert_at" not in columns:
with op.batch_alter_table("webhook_config", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_stale_alert_at", sa.DateTime(), nullable=True))
Expand All @@ -33,7 +33,7 @@ def downgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [c["name"] for c in inspector.get_columns("webhook_config")]

if "last_stale_alert_at" in columns:
with op.batch_alter_table("webhook_config", schema=None) as batch_op:
batch_op.drop_column("last_stale_alert_at")
Expand Down
Loading