From fb67252bc89d5b31a702e671acf25689ef71d261 Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Mon, 21 Sep 2026 10:46:09 -0400 Subject: [PATCH] feat(ops): add speedtest, cms persistence, support ticket db storage, and action resiliency (#18) Co-authored-by: Developer --- ...1a2b3c4d5e8_add_support_tickets_and_cms.py | 92 ++ .../app/infrastructure_metrics.py | 133 +++ backend/motorsignal_api/app/main.py | 909 +++++++++++++++--- backend/motorsignal_api/app/models.py | 68 ++ .../app/ops_actions_service.py | 22 +- .../motorsignal_api/tests/test_api_smoke.py | 1 + .../tests/test_ops_users_and_cms.py | 240 +++++ scripts/deploy-website.ps1 | 22 +- website/app/api/cms/[slug]/route.ts | 40 + website/app/api/ops/actions/route.ts | 21 +- .../api/ops/infrastructure/speedtest/route.ts | 62 ++ website/app/api/ops/users/route.ts | 86 ++ website/components/landing/HeroSection.tsx | 57 +- website/components/ops/operations-actions.tsx | 17 +- .../ops/operations-infrastructure.tsx | 128 ++- .../components/ops/operations-overview.tsx | 49 +- website/components/ops/operations-users.tsx | 240 +++++ website/components/ops/types.ts | 16 + 18 files changed, 2013 insertions(+), 190 deletions(-) create mode 100644 backend/motorsignal_api/alembic/versions/f1a2b3c4d5e8_add_support_tickets_and_cms.py create mode 100644 backend/motorsignal_api/tests/test_ops_users_and_cms.py create mode 100644 website/app/api/cms/[slug]/route.ts create mode 100644 website/app/api/ops/infrastructure/speedtest/route.ts diff --git a/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e8_add_support_tickets_and_cms.py b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e8_add_support_tickets_and_cms.py new file mode 100644 index 0000000..893fca3 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e8_add_support_tickets_and_cms.py @@ -0,0 +1,92 @@ +"""add ops support tickets and cms documents + +Revision ID: f1a2b3c4d5e8 +Revises: f1a2b3c4d5e7 +Create Date: 2026-09-21 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e8' +down_revision = 'f1a2b3c4d5e7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Sequence for support case references (SUP-YYYY-NNNNNN) + op.execute("CREATE SEQUENCE IF NOT EXISTS ops.support_case_seq START 101") + + # 2. ops.support_tickets table + op.create_table( + 'support_tickets', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('case_reference', sa.String(length=32), nullable=False, unique=True), + sa.Column('ticket_type', sa.String(length=64), nullable=False), + sa.Column('reporter_email', sa.String(length=320), nullable=False), + sa.Column('subject', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('severity', sa.String(length=32), nullable=False, server_default='medium'), + sa.Column('status', sa.String(length=32), nullable=False, server_default='open'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('diagnostic_bundle', sa.JSON(), nullable=True), + schema='ops' + ) + op.create_index('ix_ops_support_tickets_case_reference', 'support_tickets', ['case_reference'], unique=True, schema='ops') + op.create_index('ix_ops_support_tickets_status', 'support_tickets', ['status'], unique=False, schema='ops') + op.create_index('ix_ops_support_tickets_severity', 'support_tickets', ['severity'], unique=False, schema='ops') + op.create_index('ix_ops_support_tickets_created_at', 'support_tickets', ['created_at'], unique=False, schema='ops') + + # 3. ops.support_ticket_notes table + op.create_table( + 'support_ticket_notes', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('ticket_id', UUID(as_uuid=True), sa.ForeignKey('ops.support_tickets.id', ondelete='CASCADE'), nullable=False), + sa.Column('author_identity', sa.String(length=320), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + schema='ops' + ) + op.create_index('ix_ops_support_ticket_notes_ticket_id', 'support_ticket_notes', ['ticket_id'], unique=False, schema='ops') + op.create_index('ix_ops_support_ticket_notes_created_at', 'support_ticket_notes', ['created_at'], unique=False, schema='ops') + + # 4. ops.cms_documents table + op.create_table( + 'cms_documents', + sa.Column('slug', sa.String(length=96), primary_key=True), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('published_json', sa.JSON(), nullable=False), + sa.Column('draft_json', sa.JSON(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_by', sa.String(length=320), nullable=False, server_default='system'), + schema='ops' + ) + + # 5. ops.cms_document_revisions table + op.create_table( + 'cms_document_revisions', + sa.Column('revision_id', sa.String(length=96), primary_key=True), + sa.Column('document_slug', sa.String(length=96), sa.ForeignKey('ops.cms_documents.slug', ondelete='CASCADE'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('created_by', sa.String(length=320), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('change_summary', sa.Text(), nullable=False), + sa.Column('content_json', sa.JSON(), nullable=False), + schema='ops' + ) + op.create_index('ix_ops_cms_revisions_slug', 'cms_document_revisions', ['document_slug'], unique=False, schema='ops') + op.create_index('ix_ops_cms_revisions_created_at', 'cms_document_revisions', ['created_at'], unique=False, schema='ops') + + +def downgrade() -> None: + op.drop_table('cms_document_revisions', schema='ops') + op.drop_table('cms_documents', schema='ops') + op.drop_table('support_ticket_notes', schema='ops') + op.drop_table('support_tickets', schema='ops') + op.execute("DROP SEQUENCE IF EXISTS ops.support_case_seq") diff --git a/backend/motorsignal_api/app/infrastructure_metrics.py b/backend/motorsignal_api/app/infrastructure_metrics.py index 26d0752..a7af37a 100644 --- a/backend/motorsignal_api/app/infrastructure_metrics.py +++ b/backend/motorsignal_api/app/infrastructure_metrics.py @@ -494,3 +494,136 @@ def get_infrastructure_metrics(database_ok: bool = True) -> Dict[str, Any]: "deployment": get_deployment_health(database_ok=database_ok), "collected_at": datetime.now(timezone.utc).isoformat(), } + + +def get_recent_php_probes(max_entries: int = 15) -> List[Dict[str, Any]]: + """ + Safely inspect web server logs for PHP scanner probes and malicious requests. + Degrades safely to empty list if logs are absent or unreadable. + """ + log_candidates = [ + "/var/log/nginx/access.log", + "/var/log/nginx/error.log", + ] + probes: List[Dict[str, Any]] = [] + log_line_regex = re.compile( + r'^(?P\S+)\s+\S+\s+\S+\s+\[(?P