From 9aa13a508fb793c50442831288fb223900192ab1 Mon Sep 17 00:00:00 2001 From: Ricel Leite Date: Wed, 18 Feb 2026 16:38:46 -0300 Subject: [PATCH] feat: Initial TicketHub release - FastAPI backend with SQLite - Projects, Tickets, Comments, Webhooks - Modern web UI (TailwindCSS) - Webhook support for integrations - Docker support --- README.md | 89 +++++++- backend/Dockerfile | 14 ++ backend/app/main.py | 41 ++++ backend/app/models/__init__.py | 66 ++++++ backend/app/routers/health.py | 7 + backend/app/routers/projects.py | 65 ++++++ backend/app/routers/tickets.py | 205 +++++++++++++++++++ backend/app/routers/webhooks.py | 82 ++++++++ backend/app/services/__init__.py | 0 backend/app/services/database.py | 68 +++++++ backend/app/services/webhook.py | 47 +++++ backend/requirements.txt | 6 + docker-compose.yml | 16 ++ frontend/static/index.html | 339 +++++++++++++++++++++++++++++++ 14 files changed, 1043 insertions(+), 2 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/routers/health.py create mode 100644 backend/app/routers/projects.py create mode 100644 backend/app/routers/tickets.py create mode 100644 backend/app/routers/webhooks.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/database.py create mode 100644 backend/app/services/webhook.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/static/index.html diff --git a/README.md b/README.md index be20a4f..774f1d0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,88 @@ -# tickethub +# 🎫 TicketHub -Lightweight open-source ticket/issue tracking system with webhooks \ No newline at end of file +Lightweight open-source ticket/issue tracking system with webhook support. + +## Features + +- **Projects** - Organize tickets by project with unique keys (e.g., PROJ-123) +- **Tickets** - Create, update, and track issues with status and priority +- **Comments** - Add comments to tickets for collaboration +- **Webhooks** - Trigger external systems on ticket events +- **Simple** - SQLite database, no complex setup required + +## Quick Start + +### Docker + +```bash +docker-compose up -d +``` + +Access at http://localhost:8080 + +### Manual + +```bash +cd backend +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +## API Endpoints + +### Projects +- `GET /api/projects` - List all projects +- `POST /api/projects` - Create project +- `GET /api/projects/{id}` - Get project +- `DELETE /api/projects/{id}` - Delete project + +### Tickets +- `GET /api/tickets` - List tickets (filter by `project_id`, `status`) +- `POST /api/tickets` - Create ticket +- `GET /api/tickets/{id}` - Get ticket +- `GET /api/tickets/key/{key}` - Get ticket by key (e.g., PROJ-123) +- `PATCH /api/tickets/{id}` - Update ticket +- `DELETE /api/tickets/{id}` - Delete ticket + +### Comments +- `GET /api/tickets/{id}/comments` - List comments +- `POST /api/tickets/{id}/comments` - Add comment + +### Webhooks +- `GET /api/webhooks` - List webhooks +- `POST /api/webhooks` - Create webhook +- `DELETE /api/webhooks/{id}` - Delete webhook +- `PATCH /api/webhooks/{id}/toggle` - Enable/disable webhook + +## Webhook Events + +When configured, TicketHub sends POST requests to your webhook URL: + +```json +{ + "event": "ticket.created", + "timestamp": "2026-02-18T12:00:00Z", + "data": { + "id": 1, + "key": "PROJ-1", + "title": "Issue title", + "description": "...", + "status": "open", + "priority": "medium" + } +} +``` + +Events: `ticket.created`, `ticket.updated`, `comment.added` + +## Integration with JIRA AI Fixer + +Configure webhook URL in your project pointing to JIRA AI Fixer: + +``` +https://jira-fixer.example.com/api/webhook/tickethub +``` + +## License + +MIT diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..14cbf8c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +ENV DATABASE_PATH=/data/tickethub.db + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..ff5bfdb --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,41 @@ +""" +TicketHub - Lightweight Issue Tracking System +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import os + +from app.routers import tickets, projects, webhooks, health +from app.services.database import init_db + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + +app = FastAPI( + title="TicketHub", + description="Lightweight open-source ticket/issue tracking system", + version="1.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routes +app.include_router(health.router, prefix="/api", tags=["health"]) +app.include_router(projects.router, prefix="/api/projects", tags=["projects"]) +app.include_router(tickets.router, prefix="/api/tickets", tags=["tickets"]) +app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"]) + +# Serve frontend +if os.path.exists("/app/static"): + app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..eef4f13 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from enum import Enum + +class TicketStatus(str, Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + +class TicketPriority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + +class ProjectCreate(BaseModel): + name: str + key: str = Field(..., pattern="^[A-Z]{2,10}$") + description: Optional[str] = None + webhook_url: Optional[str] = None + +class Project(ProjectCreate): + id: int + created_at: datetime + ticket_count: int = 0 + +class TicketCreate(BaseModel): + project_id: int + title: str + description: str + priority: TicketPriority = TicketPriority.MEDIUM + labels: List[str] = [] + +class Ticket(TicketCreate): + id: int + key: str # e.g., "PROJ-123" + status: TicketStatus = TicketStatus.OPEN + created_at: datetime + updated_at: datetime + +class TicketUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[TicketStatus] = None + priority: Optional[TicketPriority] = None + labels: Optional[List[str]] = None + +class Comment(BaseModel): + id: int + ticket_id: int + author: str + content: str + created_at: datetime + +class CommentCreate(BaseModel): + author: str = "system" + content: str + +class WebhookConfig(BaseModel): + id: int + project_id: int + url: str + events: List[str] = ["ticket.created", "ticket.updated", "comment.added"] + active: bool = True diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..5cdb256 --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/health") +async def health_check(): + return {"status": "healthy", "service": "tickethub"} diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..2bbba2c --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, HTTPException +from typing import List +from app.models import Project, ProjectCreate +from app.services.database import get_db +import json + +router = APIRouter() + +@router.get("", response_model=List[Project]) +async def list_projects(): + db = await get_db() + cursor = await db.execute(""" + SELECT p.*, COUNT(t.id) as ticket_count + FROM projects p + LEFT JOIN tickets t ON p.id = t.project_id + GROUP BY p.id + """) + rows = await cursor.fetchall() + await db.close() + return [dict(row) for row in rows] + +@router.post("", response_model=Project) +async def create_project(project: ProjectCreate): + db = await get_db() + try: + cursor = await db.execute( + "INSERT INTO projects (name, key, description, webhook_url) VALUES (?, ?, ?, ?)", + (project.name, project.key.upper(), project.description, project.webhook_url) + ) + await db.commit() + project_id = cursor.lastrowid + + cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (project_id,)) + row = await cursor.fetchone() + await db.close() + return {**dict(row), "ticket_count": 0} + except Exception as e: + await db.close() + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/{project_id}", response_model=Project) +async def get_project(project_id: int): + db = await get_db() + cursor = await db.execute(""" + SELECT p.*, COUNT(t.id) as ticket_count + FROM projects p + LEFT JOIN tickets t ON p.id = t.project_id + WHERE p.id = ? + GROUP BY p.id + """, (project_id,)) + row = await cursor.fetchone() + await db.close() + if not row: + raise HTTPException(status_code=404, detail="Project not found") + return dict(row) + +@router.delete("/{project_id}") +async def delete_project(project_id: int): + db = await get_db() + await db.execute("DELETE FROM tickets WHERE project_id = ?", (project_id,)) + await db.execute("DELETE FROM webhooks WHERE project_id = ?", (project_id,)) + await db.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + await db.commit() + await db.close() + return {"status": "deleted"} diff --git a/backend/app/routers/tickets.py b/backend/app/routers/tickets.py new file mode 100644 index 0000000..feef17b --- /dev/null +++ b/backend/app/routers/tickets.py @@ -0,0 +1,205 @@ +from fastapi import APIRouter, HTTPException, Query +from typing import List, Optional +from app.models import Ticket, TicketCreate, TicketUpdate, Comment, CommentCreate, TicketStatus +from app.services.database import get_db +from app.services.webhook import trigger_webhook +import json +from datetime import datetime + +router = APIRouter() + +@router.get("", response_model=List[Ticket]) +async def list_tickets( + project_id: Optional[int] = None, + status: Optional[TicketStatus] = None, + limit: int = Query(default=50, le=100) +): + db = await get_db() + query = "SELECT * FROM tickets WHERE 1=1" + params = [] + + if project_id: + query += " AND project_id = ?" + params.append(project_id) + if status: + query += " AND status = ?" + params.append(status.value) + + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + + cursor = await db.execute(query, params) + rows = await cursor.fetchall() + await db.close() + + tickets = [] + for row in rows: + ticket = dict(row) + ticket["labels"] = json.loads(ticket.get("labels", "[]")) + tickets.append(ticket) + return tickets + +@router.post("", response_model=Ticket) +async def create_ticket(ticket: TicketCreate): + db = await get_db() + + # Get project and increment sequence + cursor = await db.execute("SELECT * FROM projects WHERE id = ?", (ticket.project_id,)) + project = await cursor.fetchone() + if not project: + await db.close() + raise HTTPException(status_code=404, detail="Project not found") + + project = dict(project) + new_seq = project["ticket_sequence"] + 1 + ticket_key = f"{project['key']}-{new_seq}" + + await db.execute("UPDATE projects SET ticket_sequence = ? WHERE id = ?", (new_seq, ticket.project_id)) + + now = datetime.utcnow().isoformat() + cursor = await db.execute( + """INSERT INTO tickets (project_id, key, title, description, priority, labels, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (ticket.project_id, ticket_key, ticket.title, ticket.description, + ticket.priority.value, json.dumps(ticket.labels), now, now) + ) + await db.commit() + + ticket_id = cursor.lastrowid + cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)) + row = await cursor.fetchone() + await db.close() + + result = dict(row) + result["labels"] = json.loads(result.get("labels", "[]")) + + # Trigger webhook + await trigger_webhook(ticket.project_id, "ticket.created", result) + + return result + +@router.get("/{ticket_id}", response_model=Ticket) +async def get_ticket(ticket_id: int): + db = await get_db() + cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)) + row = await cursor.fetchone() + await db.close() + if not row: + raise HTTPException(status_code=404, detail="Ticket not found") + result = dict(row) + result["labels"] = json.loads(result.get("labels", "[]")) + return result + +@router.get("/key/{ticket_key}", response_model=Ticket) +async def get_ticket_by_key(ticket_key: str): + db = await get_db() + cursor = await db.execute("SELECT * FROM tickets WHERE key = ?", (ticket_key.upper(),)) + row = await cursor.fetchone() + await db.close() + if not row: + raise HTTPException(status_code=404, detail="Ticket not found") + result = dict(row) + result["labels"] = json.loads(result.get("labels", "[]")) + return result + +@router.patch("/{ticket_id}", response_model=Ticket) +async def update_ticket(ticket_id: int, update: TicketUpdate): + db = await get_db() + + cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)) + existing = await cursor.fetchone() + if not existing: + await db.close() + raise HTTPException(status_code=404, detail="Ticket not found") + + existing = dict(existing) + updates = [] + params = [] + + if update.title is not None: + updates.append("title = ?") + params.append(update.title) + if update.description is not None: + updates.append("description = ?") + params.append(update.description) + if update.status is not None: + updates.append("status = ?") + params.append(update.status.value) + if update.priority is not None: + updates.append("priority = ?") + params.append(update.priority.value) + if update.labels is not None: + updates.append("labels = ?") + params.append(json.dumps(update.labels)) + + if updates: + updates.append("updated_at = ?") + params.append(datetime.utcnow().isoformat()) + params.append(ticket_id) + + await db.execute(f"UPDATE tickets SET {', '.join(updates)} WHERE id = ?", params) + await db.commit() + + cursor = await db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)) + row = await cursor.fetchone() + await db.close() + + result = dict(row) + result["labels"] = json.loads(result.get("labels", "[]")) + + # Trigger webhook + await trigger_webhook(existing["project_id"], "ticket.updated", result) + + return result + +@router.delete("/{ticket_id}") +async def delete_ticket(ticket_id: int): + db = await get_db() + await db.execute("DELETE FROM comments WHERE ticket_id = ?", (ticket_id,)) + await db.execute("DELETE FROM tickets WHERE id = ?", (ticket_id,)) + await db.commit() + await db.close() + return {"status": "deleted"} + +# Comments +@router.get("/{ticket_id}/comments", response_model=List[Comment]) +async def list_comments(ticket_id: int): + db = await get_db() + cursor = await db.execute( + "SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC", + (ticket_id,) + ) + rows = await cursor.fetchall() + await db.close() + return [dict(row) for row in rows] + +@router.post("/{ticket_id}/comments", response_model=Comment) +async def add_comment(ticket_id: int, comment: CommentCreate): + db = await get_db() + + cursor = await db.execute("SELECT project_id FROM tickets WHERE id = ?", (ticket_id,)) + ticket = await cursor.fetchone() + if not ticket: + await db.close() + raise HTTPException(status_code=404, detail="Ticket not found") + + cursor = await db.execute( + "INSERT INTO comments (ticket_id, author, content) VALUES (?, ?, ?)", + (ticket_id, comment.author, comment.content) + ) + await db.commit() + + comment_id = cursor.lastrowid + cursor = await db.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)) + row = await cursor.fetchone() + await db.close() + + result = dict(row) + + # Trigger webhook + await trigger_webhook(ticket["project_id"], "comment.added", { + "ticket_id": ticket_id, + "comment": result + }) + + return result diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py new file mode 100644 index 0000000..80d68c2 --- /dev/null +++ b/backend/app/routers/webhooks.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, HTTPException +from typing import List +from pydantic import BaseModel +from app.services.database import get_db +import json + +router = APIRouter() + +class WebhookCreate(BaseModel): + project_id: int + url: str + events: List[str] = ["ticket.created", "ticket.updated", "comment.added"] + +class WebhookResponse(BaseModel): + id: int + project_id: int + url: str + events: List[str] + active: bool + +@router.get("", response_model=List[WebhookResponse]) +async def list_webhooks(project_id: int = None): + db = await get_db() + if project_id: + cursor = await db.execute("SELECT * FROM webhooks WHERE project_id = ?", (project_id,)) + else: + cursor = await db.execute("SELECT * FROM webhooks") + rows = await cursor.fetchall() + await db.close() + + result = [] + for row in rows: + wh = dict(row) + wh["events"] = json.loads(wh.get("events", "[]")) + wh["active"] = bool(wh.get("active", 1)) + result.append(wh) + return result + +@router.post("", response_model=WebhookResponse) +async def create_webhook(webhook: WebhookCreate): + db = await get_db() + cursor = await db.execute( + "INSERT INTO webhooks (project_id, url, events) VALUES (?, ?, ?)", + (webhook.project_id, webhook.url, json.dumps(webhook.events)) + ) + await db.commit() + webhook_id = cursor.lastrowid + + cursor = await db.execute("SELECT * FROM webhooks WHERE id = ?", (webhook_id,)) + row = await cursor.fetchone() + await db.close() + + result = dict(row) + result["events"] = json.loads(result.get("events", "[]")) + result["active"] = bool(result.get("active", 1)) + return result + +@router.delete("/{webhook_id}") +async def delete_webhook(webhook_id: int): + db = await get_db() + await db.execute("DELETE FROM webhooks WHERE id = ?", (webhook_id,)) + await db.commit() + await db.close() + return {"status": "deleted"} + +@router.patch("/{webhook_id}/toggle") +async def toggle_webhook(webhook_id: int): + db = await get_db() + await db.execute("UPDATE webhooks SET active = NOT active WHERE id = ?", (webhook_id,)) + await db.commit() + + cursor = await db.execute("SELECT * FROM webhooks WHERE id = ?", (webhook_id,)) + row = await cursor.fetchone() + await db.close() + + if not row: + raise HTTPException(status_code=404, detail="Webhook not found") + + result = dict(row) + result["events"] = json.loads(result.get("events", "[]")) + result["active"] = bool(result.get("active", 1)) + return result diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/database.py b/backend/app/services/database.py new file mode 100644 index 0000000..8110bdf --- /dev/null +++ b/backend/app/services/database.py @@ -0,0 +1,68 @@ +""" +SQLite database for simplicity. Can be swapped for PostgreSQL. +""" +import aiosqlite +import os +from datetime import datetime + +DB_PATH = os.getenv("DATABASE_PATH", "/data/tickethub.db") + +async def get_db(): + db = await aiosqlite.connect(DB_PATH) + db.row_factory = aiosqlite.Row + return db + +async def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + db = await get_db() + + await db.executescript(""" + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key TEXT UNIQUE NOT NULL, + description TEXT, + webhook_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ticket_sequence INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + key TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'open', + priority TEXT DEFAULT 'medium', + labels TEXT DEFAULT '[]', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) + ); + + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + author TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(id) + ); + + CREATE TABLE IF NOT EXISTS webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + url TEXT NOT NULL, + events TEXT DEFAULT '["ticket.created","ticket.updated","comment.added"]', + active INTEGER DEFAULT 1, + FOREIGN KEY (project_id) REFERENCES projects(id) + ); + + CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id); + CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); + CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id); + """) + + await db.commit() + await db.close() diff --git a/backend/app/services/webhook.py b/backend/app/services/webhook.py new file mode 100644 index 0000000..2f3572f --- /dev/null +++ b/backend/app/services/webhook.py @@ -0,0 +1,47 @@ +""" +Webhook dispatcher - sends events to configured URLs +""" +import httpx +import json +from datetime import datetime +from app.services.database import get_db + +async def trigger_webhook(project_id: int, event: str, payload: dict): + """Send webhook to all configured endpoints for this project""" + db = await get_db() + cursor = await db.execute( + "SELECT * FROM webhooks WHERE project_id = ? AND active = 1", + (project_id,) + ) + webhooks = await cursor.fetchall() + + # Also check project's default webhook_url + cursor = await db.execute("SELECT webhook_url FROM projects WHERE id = ?", (project_id,)) + project = await cursor.fetchone() + await db.close() + + urls = [] + for wh in webhooks: + wh = dict(wh) + events = json.loads(wh.get("events", "[]")) + if event in events or not events: + urls.append(wh["url"]) + + if project and project["webhook_url"]: + urls.append(project["webhook_url"]) + + # Deduplicate + urls = list(set(urls)) + + webhook_payload = { + "event": event, + "timestamp": datetime.utcnow().isoformat(), + "data": payload + } + + async with httpx.AsyncClient(timeout=10.0) as client: + for url in urls: + try: + await client.post(url, json=webhook_payload) + except Exception as e: + print(f"Webhook failed for {url}: {e}") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..06a9991 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.109.0 +uvicorn==0.27.0 +aiosqlite==0.19.0 +httpx==0.26.0 +pydantic==2.5.3 +python-multipart==0.0.6 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d8624e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.8" + +services: + tickethub: + build: ./backend + ports: + - "8080:8000" + volumes: + - tickethub_data:/data + - ./frontend/static:/app/static:ro + environment: + - DATABASE_PATH=/data/tickethub.db + restart: unless-stopped + +volumes: + tickethub_data: diff --git a/frontend/static/index.html b/frontend/static/index.html new file mode 100644 index 0000000..29511fe --- /dev/null +++ b/frontend/static/index.html @@ -0,0 +1,339 @@ + + + + + + TicketHub + + + + + +
+ +
+
+
+ 🎫 +

TicketHub

+
+ +
+
+ + +
+ +
+

Projects

+
+ +
+
+ + +
+
+

Tickets

+ +
+
+
Select a project to view tickets
+
+
+
+
+ + + + + + + + + + + + +