diff --git a/README.md b/README.md index 8148dae..edd1f16 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,62 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat **Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets) **Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps +![Tinker Tickets walkthrough: dashboard, kanban, ticket detail with SLA timer, comments, dependencies, activity timeline, command palette and workflow designer](docs/img/demo.gif) + +## Why this exists + +Running a six-node Proxmox/Ceph cluster generates a steady stream of small problems: a drive with SMART warnings, a link that flaps, a certificate about to expire. Off-the-shelf helpdesks are heavyweight for that, and a plain todo list loses the history. Tinker Tickets is a small, opinionated ticketing system built for exactly this: tickets are created **automatically by monitoring** (and by hand), deduplicated so a repeating alert updates one ticket instead of flooding the queue, and every change is audited. + +```mermaid +flowchart LR + hw["hwmonDaemon
SMART, Ceph, temps"] -->|Bearer API| tt + gd["GANDALF
network regressions"] -->|Bearer API| tt + mcp["MCP server
Claude Code / agents (OAuth)"] --> tt + ui["Web UI
people"] --> tt + tt{{"Tinker Tickets
PHP + MariaDB"}} -->|webhook| mx["Matrix notifications"] + tt --> audit[("Audit log
timeline per ticket")] +``` + +## Gallery + +**Dashboard**: live stat cards, priority and status breakdowns, team workload and a filterable queue. + +![Dashboard](docs/img/dashboard.png) + +**Kanban view** with per-column counts and priority-coloured cards: + +![Kanban board](docs/img/kanban.png) + +**Ticket detail** with a live P1/P2 SLA timer, full metadata and a tabbed workspace: + +![Ticket with SLA banner](docs/img/ticket-sla.png) + +| Comments (Markdown, @mentions) | Dependencies (blocks / relates to) | +|---|---| +| ![Comments](docs/img/ticket-comments.png) | ![Dependencies](docs/img/ticket-dependencies.png) | + +**Activity timeline**, derived from the audit log (creation, assignment, status changes, comments): + +![Activity timeline](docs/img/ticket-activity.png) + +**Command palette** (`Ctrl+K`) jumps to pages, saved filters and recently viewed tickets: + +![Command palette](docs/img/command-palette.png) + +**Admin tools:** + +| Workflow designer | API keys (scoped, revocable) | +|---|---| +| ![Workflow designer](docs/img/admin-workflow.png) | ![API keys](docs/img/admin-api-keys.png) | +| **Audit log** | **Recurring tickets** | +| ![Audit log](docs/img/admin-audit-log.png) | ![Recurring tickets](docs/img/admin-recurring.png) | + +**Light theme** (the whole app follows the [web_template](https://code.lotusguild.org/LotusGuild/web_template) theme toggle): + +![Dashboard, light theme](docs/img/dashboard-light.png) + +Screenshots are generated with Playwright against a throwaway local instance seeded with fictional demo data (`scripts/demo/`). + ## Styling & Layout Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see: diff --git a/docs/img/admin-api-keys.png b/docs/img/admin-api-keys.png new file mode 100644 index 0000000..9ffe0ad Binary files /dev/null and b/docs/img/admin-api-keys.png differ diff --git a/docs/img/admin-audit-log.png b/docs/img/admin-audit-log.png new file mode 100644 index 0000000..b166932 Binary files /dev/null and b/docs/img/admin-audit-log.png differ diff --git a/docs/img/admin-recurring.png b/docs/img/admin-recurring.png new file mode 100644 index 0000000..0a02d8a Binary files /dev/null and b/docs/img/admin-recurring.png differ diff --git a/docs/img/admin-workflow.png b/docs/img/admin-workflow.png new file mode 100644 index 0000000..3f32499 Binary files /dev/null and b/docs/img/admin-workflow.png differ diff --git a/docs/img/command-palette.png b/docs/img/command-palette.png new file mode 100644 index 0000000..1273a35 Binary files /dev/null and b/docs/img/command-palette.png differ diff --git a/docs/img/dashboard-light.png b/docs/img/dashboard-light.png new file mode 100644 index 0000000..59e7be2 Binary files /dev/null and b/docs/img/dashboard-light.png differ diff --git a/docs/img/dashboard.png b/docs/img/dashboard.png new file mode 100644 index 0000000..3e34fc6 Binary files /dev/null and b/docs/img/dashboard.png differ diff --git a/docs/img/demo.gif b/docs/img/demo.gif new file mode 100644 index 0000000..c14e880 Binary files /dev/null and b/docs/img/demo.gif differ diff --git a/docs/img/kanban.png b/docs/img/kanban.png new file mode 100644 index 0000000..03f55e1 Binary files /dev/null and b/docs/img/kanban.png differ diff --git a/docs/img/new-ticket.png b/docs/img/new-ticket.png new file mode 100644 index 0000000..f917e35 Binary files /dev/null and b/docs/img/new-ticket.png differ diff --git a/docs/img/ticket-activity.png b/docs/img/ticket-activity.png new file mode 100644 index 0000000..b04a7aa Binary files /dev/null and b/docs/img/ticket-activity.png differ diff --git a/docs/img/ticket-comments.png b/docs/img/ticket-comments.png new file mode 100644 index 0000000..e754fd0 Binary files /dev/null and b/docs/img/ticket-comments.png differ diff --git a/docs/img/ticket-dependencies.png b/docs/img/ticket-dependencies.png new file mode 100644 index 0000000..614e285 Binary files /dev/null and b/docs/img/ticket-dependencies.png differ diff --git a/docs/img/ticket-queue.png b/docs/img/ticket-queue.png new file mode 100644 index 0000000..cf13fd0 Binary files /dev/null and b/docs/img/ticket-queue.png differ diff --git a/docs/img/ticket-sla.png b/docs/img/ticket-sla.png new file mode 100644 index 0000000..7dec738 Binary files /dev/null and b/docs/img/ticket-sla.png differ diff --git a/scripts/demo/README.md b/scripts/demo/README.md new file mode 100644 index 0000000..1067e9b --- /dev/null +++ b/scripts/demo/README.md @@ -0,0 +1,19 @@ +# Demo environment (for README screenshots) + +Everything here uses **fictional data only**. It spins up a throwaway MariaDB on a private socket and PHP's +built-in server, so nothing touches a real database. + +```bash +D=$(mktemp -d); cp -r . $D/tinker && cp scripts/demo/router.php scripts/demo/seed.py scripts/demo/capture.py $D/ +cd $D +printf 'DB_HOST=localhost\nDB_USER=root\nDB_PASS=\nDB_NAME=ticketing_system\n' > tinker/.env +mariadb-install-db --no-defaults --datadir=$D/db/data --auth-root-authentication-method=normal --skip-test-db +mariadbd --no-defaults --datadir=$D/db/data --socket=$D/db/sock --skip-networking --user=root & +mariadb --no-defaults -S $D/db/sock -e 'CREATE DATABASE ticketing_system CHARACTER SET utf8mb4' +(cd tinker && php -d mysqli.default_socket=$D/db/sock migrations/migrate.php) +python3 seed.py && mariadb --no-defaults -S $D/db/sock ticketing_system < seed.sql +php -d mysqli.default_socket=$D/db/sock -S 127.0.0.1:8300 -t tinker router.php & +python3 capture.py # screenshots + GIF -> docs/img/ +``` + +The app trusts Authelia `Remote-User` headers, so `capture.py` sends them directly. Requires `playwright` and `pillow`. diff --git a/scripts/demo/capture.py b/scripts/demo/capture.py new file mode 100644 index 0000000..30d2ece --- /dev/null +++ b/scripts/demo/capture.py @@ -0,0 +1,78 @@ +import asyncio, io, sys +from PIL import Image +from playwright.async_api import async_playwright +import pathlib +OUT=str(pathlib.Path(__file__).resolve().parents[2]/'docs'/'img') +B='http://127.0.0.1:8300' +H={'Remote-User':'alex.rivera','Remote-Name':'Alex Rivera','Remote-Email':'alex@example.com','Remote-Groups':'admin,employee'} +P1='152847156'; CEPH='524938499' +JS_TOP="document.activeElement&&document.activeElement.blur();document.documentElement.style.scrollBehavior='auto';window.scrollTo(0,0)" +async def ctxmk(p,theme='dark',w=1440,h=900): + b=await p.chromium.launch(); c=await b.new_context(viewport={'width':w,'height':h},extra_http_headers=H) + await c.add_init_script(f"try{{localStorage.setItem('lt_theme','{theme}')}}catch(e){{}}") + async def avatar(route): + import re + uid=int((re.search(r'user_id=(\d+)',route.request.url) or [0,0])[1] or 0) + ini={1:'AR',2:'SC',3:'RB',4:'JK',5:'HW'}.get(uid,'??'); col={1:'#ff6b00',2:'#00d4ff',3:'#b44aff',4:'#00ff88',5:'#ffc800'}.get(uid,'#888') + svg=f'{ini}' + await route.fulfill(status=200,content_type='image/svg+xml',body=svg) + await c.route('**/api/user_avatar.php*',avatar) + return b,c +async def go(pg,path,wait=3500): + await pg.goto(B+path); await pg.wait_for_timeout(wait); await pg.evaluate(JS_TOP); await pg.wait_for_timeout(300) +async def tab(pg,name): + t=pg.get_by_role('tab',name=name).first + if await t.count()==0: t=pg.get_by_text(name,exact=False).first + await t.click(timeout=4000); await pg.wait_for_timeout(900) +async def shots(): + async with async_playwright() as p: + b,c=await ctxmk(p); pg=await c.new_page() + await go(pg,'/',5000); await pg.screenshot(path=f'{OUT}/dashboard.png') + # table crop + await pg.set_viewport_size({'width':1840,'height':900}); await pg.wait_for_timeout(600) + await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})"); await pg.wait_for_timeout(500) + await pg.screenshot(path=f'{OUT}/ticket-queue.png') + await pg.set_viewport_size({'width':1440,'height':900}); await pg.wait_for_timeout(600) + try: await tab(pg,'Kanban'); await pg.screenshot(path=f'{OUT}/kanban.png'); print('kanban ok') + except Exception as e: print('kanban fail',str(e)[:80]) + await go(pg,'/ticket/'+P1); await pg.screenshot(path=f'{OUT}/ticket-sla.png') + await go(pg,'/ticket/'+CEPH) + for name,fn in (('Comments','ticket-comments'),('Dependencies','ticket-dependencies'),('Activity','ticket-activity')): + try: await tab(pg,name); await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})"); await pg.wait_for_timeout(500); await pg.screenshot(path=f'{OUT}/{fn}.png'); print(fn,'ok') + except Exception as e: print(fn,'fail',str(e)[:80]) + await go(pg,'/ticket/create',3000); await pg.screenshot(path=f'{OUT}/new-ticket.png') + await go(pg,'/'); await pg.keyboard.press('Control+k'); await pg.wait_for_timeout(500); await pg.keyboard.type('ticket',delay=70); await pg.wait_for_timeout(900) + await pg.screenshot(path=f'{OUT}/command-palette.png', clip={'x':320,'y':60,'width':800,'height':460}) + await pg.keyboard.press('Escape') + for path,name in (('/admin/workflow','admin-workflow'),('/admin/api-keys','admin-api-keys'),('/admin/audit-log','admin-audit-log'),('/admin/recurring-tickets','admin-recurring')): + await go(pg,path,3500); await pg.screenshot(path=f'{OUT}/{name}.png'); print(name,'ok') + await b.close() + b,c=await ctxmk(p,'light'); pg=await c.new_page(); await go(pg,'/',5000); await pg.screenshot(path=f'{OUT}/dashboard-light.png'); await b.close() +async def gif(): + frames=[] + async with async_playwright() as p: + b,c=await ctxmk(p,'dark',1280,720); pg=await c.new_page() + async def snap(n=1,ms=0): + if ms: await pg.wait_for_timeout(ms) + im=Image.open(io.BytesIO(await pg.screenshot())).convert('RGB').resize((960,540),Image.LANCZOS) + for _ in range(n): frames.append(im) + await go(pg,'/',4500); await snap(3) + await pg.mouse.wheel(0,520); await snap(2,500) + try: await tab(pg,'Kanban'); await snap(3,300) + except Exception: pass + await go(pg,'/ticket/'+P1,3000); await snap(3) + await go(pg,'/ticket/'+CEPH,3000); await snap(2) + for name in ('Comments','Dependencies','Activity'): + try: await tab(pg,name); await snap(3,300) + except Exception: pass + await go(pg,'/',3000); await pg.keyboard.press('Control+k'); await snap(2,500) + for ch in 'tick': await pg.keyboard.type(ch); await snap(1,200) + await snap(3,300) + await go(pg,'/admin/workflow',3000); await snap(3) + await b.close() + pal=[f.quantize(colors=96,method=Image.MEDIANCUT,dither=Image.NONE) for f in frames] + pal[0].save(f'{OUT}/demo.gif',save_all=True,append_images=pal[1:],duration=1100,loop=0,optimize=True); print('gif',len(frames)) +async def main(): + if 'gif' not in sys.argv: await shots() + if 'shots' not in sys.argv: await gif() +asyncio.run(main()) diff --git a/scripts/demo/router.php b/scripts/demo/router.php new file mode 100644 index 0000000..e7d05d7 --- /dev/null +++ b/scripts/demo/router.php @@ -0,0 +1,6 @@ +