mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:18:42 +03:00
Merge branch '20260526_sync' into next
This commit is contained in:
10
AGENTS.md
Normal file
10
AGENTS.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Cursor / agent bootstrap
|
||||||
|
|
||||||
|
After a **reset** or new session on deploy/infra work:
|
||||||
|
|
||||||
|
1. Read **[docs/INFRA.md](docs/INFRA.md)** end-to-end (topology, **no :8089** for androidcast, BE `listen 80`, sshfs `tmp/` paths).
|
||||||
|
2. For hub deploy only: **[examples/app_hub/DEPLOY.md](examples/app_hub/DEPLOY.md)**.
|
||||||
|
3. For PHP/DB/tickets: **[examples/crash_reporter/backend/README.md](examples/crash_reporter/backend/README.md)**.
|
||||||
|
4. For git branches: **[docs/GIT_FLOW.md](docs/GIT_FLOW.md)** — work on `feature/*` from `next`; do not commit unless asked.
|
||||||
|
|
||||||
|
**Hard rule:** Port **8089** is **not** the androidcast vhost (Janus/other). Public apps path is **`https://apps.f0xx.org`** → BE **`http://artc0.intra.raptor.org:80`**.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Android Cast (POC)
|
# Android Cast (POC)
|
||||||
|
|
||||||
**Roadmap & alpha:** [docs/ROADMAP.md](docs/ROADMAP.md) · **Alpha QA & feature freeze:** [docs/ALPHA.md](docs/ALPHA.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md)
|
**Roadmap & alpha:** [docs/ROADMAP.md](docs/ROADMAP.md) · **Alpha QA & feature freeze:** [docs/ALPHA.md](docs/ALPHA.md) · **Production infra (agents: read first):** [docs/INFRA.md](docs/INFRA.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md)
|
||||||
|
|
||||||
Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **TCP or UDP** to a receiver that decodes and plays back.
|
Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **TCP or UDP** to a receiver that decodes and plays back.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Deploy tuning via app-private `files/settings.json` (see `examples/settings.json
|
|||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
PHP console and ingest API: `examples/crash_reporter/backend/` — see [README](../examples/crash_reporter/backend/README.md).
|
PHP console and ingest API: `examples/crash_reporter/backend/` — see [README](../examples/crash_reporter/backend/README.md).
|
||||||
|
Production nginx/hosts: [INFRA.md](INFRA.md).
|
||||||
|
|
||||||
Roadmap: `examples/crash_reporter/ROADMAP.md`.
|
Roadmap: `examples/crash_reporter/ROADMAP.md`.
|
||||||
|
|
||||||
|
|||||||
202
docs/INFRA.md
Normal file
202
docs/INFRA.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Android Cast — production infrastructure
|
||||||
|
|
||||||
|
**Read this first** after a context reset, before changing nginx, deploy paths, or URLs.
|
||||||
|
|
||||||
|
| Doc | When |
|
||||||
|
|-----|------|
|
||||||
|
| **This file** | Topology, hosts, ports, sshfs, agent rules |
|
||||||
|
| [examples/app_hub/DEPLOY.md](../examples/app_hub/DEPLOY.md) | Hub landing only (BE `listen 80`) |
|
||||||
|
| [examples/crash_reporter/backend/README.md](../examples/crash_reporter/backend/README.md) | PHP, MariaDB, tickets, ingest |
|
||||||
|
| [docs/GIT_FLOW.md](GIT_FLOW.md) | Branches, `next`, no direct commits to `master` |
|
||||||
|
| [docs/TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets, Gitea, alpha delegation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Topology
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet / LAN
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ FE — Gentoo HVM │
|
||||||
|
│ Public: https://apps.f0xx.org │
|
||||||
|
│ TLS termination, nginx reverse proxy│
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│ proxy_pass http://artc0.intra.raptor.org:80
|
||||||
|
│ (default location /)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ BE — Alpine VM 10.7.16.128 │
|
||||||
|
│ nginx: /etc/nginx/conf.d/apps.conf│
|
||||||
|
│ PHP-FPM unix socket │
|
||||||
|
│ MariaDB: androidcast_crashes │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Role | Host | Notes |
|
||||||
|
|------|------|--------|
|
||||||
|
| **FE** | Gentoo, `apps.f0xx.org` | Almost all app traffic → BE **:80** |
|
||||||
|
| **BE** | Alpine `artc0.intra.raptor.org` | App vhost lives in **`listen 80`** for FE traffic |
|
||||||
|
| **Jump SSH** | `f0xx.org:222` → Alpine `:22` | `~/.ssh/config` host **`alpine-be`** |
|
||||||
|
| **Git origin** | `git://f0xx.org/android_cast` | Integration branch **`next`** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Port 8089 — not the androidcast vhost
|
||||||
|
|
||||||
|
**Do not** configure or document the project hub or crash console on **`:8089`** for this installation.
|
||||||
|
|
||||||
|
| Port | Service |
|
||||||
|
|------|---------|
|
||||||
|
| **80** | **Canonical** androidcast vhost on BE (hub + crashes when FE uses `location /` or explicit `:80` upstream) |
|
||||||
|
| **443** | BE TLS (used by some FE `proxy_pass` rules, e.g. `apps.f0xx.org` → `https://artc0:443/.../crashes/`) |
|
||||||
|
| **8089** | **Other** stack (e.g. **Janus** `/janus/`) — **not** androidcast nginx vhost |
|
||||||
|
|
||||||
|
Wrong FE pattern (remove if present on **`artc0.f0xx.org`**):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_pass https://artc0.intra.raptor.org:8089/app/androidcast_project/; # BAD for hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct public path for hub:
|
||||||
|
|
||||||
|
`https://apps.f0xx.org/app/androidcast_project/` → FE `location /` → `http://artc0.intra.raptor.org:80` → BE `listen 80` locations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Public URLs
|
||||||
|
|
||||||
|
| URL | Component |
|
||||||
|
|-----|-----------|
|
||||||
|
| `https://apps.f0xx.org/app/androidcast_project/` | Static hub (`examples/app_hub/index.html`) |
|
||||||
|
| `https://apps.f0xx.org/app/androidcast_project/crashes/` | PHP crash + tickets console |
|
||||||
|
| `https://apps.f0xx.org/app/androidcast_project/crashes/api/upload.php` | Device crash upload (no session) |
|
||||||
|
|
||||||
|
Legacy hostname **`f0xx.org`** may still appear in app `settings.json` examples; production ingest/scripts prefer **`apps.f0xx.org`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Disk layout (BE)
|
||||||
|
|
||||||
|
Web root prefix:
|
||||||
|
|
||||||
|
`/var/www/localhost/htdocs/apps/app/androidcast_project/`
|
||||||
|
|
||||||
|
| Path on BE | Repo |
|
||||||
|
|------------|------|
|
||||||
|
| `.../androidcast_project/android_cast/` | Full git tree (crash backend, app sources) |
|
||||||
|
| `.../androidcast_project/index.html` | `examples/app_hub/index.html` (copy or symlink) |
|
||||||
|
| `.../android_cast/examples/crash_reporter/backend/public/` | PHP document root for crashes |
|
||||||
|
|
||||||
|
`config.php` **`base_path`** must be exactly:
|
||||||
|
|
||||||
|
`/app/androidcast_project/crashes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nginx — source of truth in repo
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **`examples/crash_reporter/backend/nginx.apps-port80.fragment`** | **Replace** BE `server { listen 80; ... }` — hub + crashes + assets |
|
||||||
|
| `examples/crash_reporter/backend/nginx.apps.conf.fragment` | Reference blocks (443-oriented comments) |
|
||||||
|
| `examples/crash_reporter/backend/nginx.vm.conf` | Full VM example |
|
||||||
|
|
||||||
|
### BE `apps.conf` today (typical)
|
||||||
|
|
||||||
|
- **`listen 80`** — often **empty** (only generic `\.php$`) → **public hub 502** until port-80 fragment is applied.
|
||||||
|
- **`listen 443 ssl`** — may already contain hub + crashes (direct HTTPS to VM).
|
||||||
|
|
||||||
|
FE **`apps.f0xx.org`** uses `proxy_pass http://artc0.intra.raptor.org:80` for `/` — so **hub must be on BE port 80**, not only 443.
|
||||||
|
|
||||||
|
### Hub location (correct)
|
||||||
|
|
||||||
|
Use **directory** `alias`, not `alias` to a single file:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /app/androidcast_project/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reload (BE only for hub deploy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh alpine-be 'sudo nginx -t && sudo rc-service nginx reload'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Local sshfs workspace (not in git)
|
||||||
|
|
||||||
|
Edit live configs from the dev machine via mounts under repo **`tmp/`**:
|
||||||
|
|
||||||
|
| Mount | Remote |
|
||||||
|
|-------|--------|
|
||||||
|
| `tmp/FE_gentoo/etc/nginx/` | Gentoo FE nginx |
|
||||||
|
| `tmp/BE_alpine/etc/nginx/conf.d/apps.conf` | Alpine BE apps vhost |
|
||||||
|
| `tmp/BE_alpine/var/www/localhost/htdocs/apps/...` | BE web tree |
|
||||||
|
|
||||||
|
Treat `tmp/` as **local mirrors** — sync/reload on the real host after edits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FE nginx — two server names
|
||||||
|
|
||||||
|
Mounted copy: `tmp/FE_gentoo/etc/nginx/nginx.conf`.
|
||||||
|
|
||||||
|
| `server_name` | `location /` | androidcast crashes | Hub `/app/androidcast_project/` |
|
||||||
|
|---------------|--------------|---------------------|--------------------------------|
|
||||||
|
| **`apps.f0xx.org`** | → `http://artc0:80` | May → `https://artc0:443/.../crashes/` | Via **`location /`** → BE :80 (no extra block needed) |
|
||||||
|
| **`artc0.f0xx.org`** | → `http://artc0:80` | Legacy → **:8089** | Legacy → **:8089** — **remove** hub block; prefer apps.f0xx.org |
|
||||||
|
|
||||||
|
**Agent default:** fix **BE `listen 80`** first. Change FE only when user asks or to delete wrong **8089** hub overrides on `artc0.f0xx.org`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# BE directly (simulates FE Host header)
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" -H "Host: apps.f0xx.org" \
|
||||||
|
"http://artc0.intra.raptor.org/app/androidcast_project/"
|
||||||
|
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" -H "Host: apps.f0xx.org" \
|
||||||
|
"http://artc0.intra.raptor.org/app/androidcast_project/crashes/"
|
||||||
|
|
||||||
|
# Public
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||||
|
"https://apps.f0xx.org/app/androidcast_project/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect **200** (or **302** → crashes only if you intentionally redirect root).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backend app (crashes / tickets)
|
||||||
|
|
||||||
|
- PHP 8.1+ FPM on Alpine; MariaDB DB **`androidcast_crashes`**.
|
||||||
|
- Migration **`003_tickets.sql`** as **MySQL root** if UI says table missing (app user cannot DDL).
|
||||||
|
- `Database::requireTicketsTable()` must open PDO before `SHOW TABLES` (fixed in repo).
|
||||||
|
- Ingest smoke tickets: `php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/androidcast_project/crashes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Rules for agents
|
||||||
|
|
||||||
|
1. **KISS** — prefer **one** BE change (`listen 80` fragment) over FE + BE + 8089 guesses.
|
||||||
|
2. **Never** point androidcast hub/crashes docs at **:8089** for this install.
|
||||||
|
3. **Do not** use `alias` to `index.html` as a file path (breaks trailing slash / CSS).
|
||||||
|
4. **Do not** commit `tmp/` sshfs trees, `*.log`, or workspace files unless user asks.
|
||||||
|
5. **Do not** commit to `master`/`next` directly — use `feature/*` → merge to `next` ([GIT_FLOW](GIT_FLOW.md)).
|
||||||
|
6. User may be unwell / slow to deploy — give **short** copy-paste steps, not long runbooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Changelog (infra doc)
|
||||||
|
|
||||||
|
| Date | Note |
|
||||||
|
|------|------|
|
||||||
|
| 2026-05-21 | Documented BE-only hub on port 80; 8089 = Janus only; FE `apps` vs `artc0` split |
|
||||||
90
docs/TICKETS_ROADMAP.md
Normal file
90
docs/TICKETS_ROADMAP.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Tickets, Gitea, and alpha smoke delegation
|
||||||
|
|
||||||
|
## Gitea (`/app/androidcast_project/git/`)
|
||||||
|
|
||||||
|
Public URL should return Gitea HTML (assets under `/app/androidcast_project/git/assets/…`).
|
||||||
|
|
||||||
|
**If clone/push fails while the page loads:**
|
||||||
|
|
||||||
|
1. **Clone URL** must include the subpath, e.g.
|
||||||
|
`https://apps.f0xx.org/app/androidcast_project/git/<owner>/<repo>.git`
|
||||||
|
2. **Gitea `app.ini`** on BE (`:3000`):
|
||||||
|
`ROOT_URL = https://apps.f0xx.org/app/androidcast_project/git/`
|
||||||
|
`DOMAIN = apps.f0xx.org`
|
||||||
|
3. **FE** `nginx.conf` ~983–996: `proxy_pass http://artc0.intra.raptor.org:3000/;` only works if Gitea is configured for that subpath (your HTML manifest suggests it is).
|
||||||
|
4. **BE**: Gitea must listen on `10.7.16.128:3000` (or `0.0.0.0:3000`); no extra nginx required unless you proxy git through `:80` too.
|
||||||
|
|
||||||
|
Symptom checklist: 502 → Gitea down; broken CSS → `ROOT_URL` wrong; 401 on push → credentials / token; 404 on clone → remote path missing subpath.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alpha smoke tickets (typical DB id → block)
|
||||||
|
|
||||||
|
Ingest order from `tmp/alpha_smoke_round1.txt` (skips pure “pass” rows):
|
||||||
|
|
||||||
|
| id (typical) | Block | Theme | Delegation |
|
||||||
|
|--------------|-------|--------|------------|
|
||||||
|
| 1 | A3 | PIN/cast OK; **rotation + preview NOK** | App: sender preview / rotation crash |
|
||||||
|
| 2 | A4 | Video OK; **frame drops, low res, few TX losses** | App: UDP/encoder quality; monitor NDK only if native crashes appear |
|
||||||
|
| 3 | A5 | Audio works; **laggy sound** | App: AAC pipeline / jitter buffer |
|
||||||
|
| 4 | A6 | Notification resolution | Often lower priority (pass with notes) |
|
||||||
|
| 5 | A7 | Stop OK; **wrong lifecycle** (resume, notifications) | App: `CastForegroundService`, notification intents, task stack |
|
||||||
|
| 6 | A9 | Repeat soak; **lifecycle** again | Same as #5 |
|
||||||
|
| 7 | (manual) | **Delegation / process** | Console: assignees, workflow, comments (this doc) |
|
||||||
|
|
||||||
|
Confirm ids in UI (title contains `Alpha smoke A3` etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Console features (implementation)
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Workflow state (`triage` → `in_progress` → `blocked` / `wont_fix` → `done`) | Migration `004` + UI |
|
||||||
|
| Assignees (0..n users, chip picker) | `assignees_json` + API |
|
||||||
|
| Comments (thread on ticket) | `ticket_comments` + API |
|
||||||
|
| Email notify reporter | Nice-to-have later |
|
||||||
|
| **Attachments** (files + links) | Migration `005` + ticket UI |
|
||||||
|
| **Google Suite** (OAuth, embed, Chat/Meet hooks) | Phase 2 — see below |
|
||||||
|
|
||||||
|
### Attachments (deploy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p androidcast_crashes < examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql
|
||||||
|
chown -R www-data:www-data examples/crash_reporter/backend/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Links:** paste HTTPS URL; Google hosts auto-tagged (Docs, Sheets, Slides, Drive, Meet, Chat, Forms).
|
||||||
|
- **Files:** stored under `backend/storage/ticket_attachments/{ticket_id}/`, download via `/api/ticket_attachment.php?id=…`.
|
||||||
|
|
||||||
|
### Google Workspace — phase 2 (not implemented)
|
||||||
|
|
||||||
|
Requires Google Cloud project, OAuth consent, service account or user OAuth:
|
||||||
|
|
||||||
|
| Product | Integration idea |
|
||||||
|
|---------|------------------|
|
||||||
|
| Docs / Sheets / Slides | Picker API → attach link + optional embed iframe |
|
||||||
|
| Drive | File picker + shared-drive ACL |
|
||||||
|
| Meet | Attach recording link after call |
|
||||||
|
| Chat | Webhook or Chat API for ticket updates |
|
||||||
|
| Gmail | Optional notify assignees |
|
||||||
|
|
||||||
|
Until then, **paste share links** — the console labels them and opens in a new tab.
|
||||||
|
|
||||||
|
**Deploy migration (MariaDB, as root):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p androidcast_crashes < examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent / engineering queue (from your list)
|
||||||
|
|
||||||
|
1. **#1 (A3 NOK)** — Reproduce rotation + preview crash; separate from PIN path.
|
||||||
|
2. **#5 / #7 / #6 (lifecycle)** — Audit cold start, recents, notification tap, stop/receive; align with alpha A7/A9.
|
||||||
|
3. **#3 (laggy audio)** — Measure end-to-end latency sender → receiver; tune buffer / sync.
|
||||||
|
4. **#2 (TX losses)** — Log UDP gaps; recovery already OK — reduce loss if easy win.
|
||||||
|
5. **NDK** — Only if crash console shows native reports for these sessions.
|
||||||
|
|
||||||
|
Use ticket **comments** for findings; set **workflow** + **assignees** when you hand off.
|
||||||
71
examples/app_hub/DEPLOY.md
Normal file
71
examples/app_hub/DEPLOY.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Landing + androidcast — BE only, port 80
|
||||||
|
|
||||||
|
**Full topology:** [docs/INFRA.md](../../docs/INFRA.md) — read after any agent/context reset.
|
||||||
|
|
||||||
|
Public: `https://apps.f0xx.org/app/androidcast_project/`
|
||||||
|
|
||||||
|
## Topology (no :8089)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser → apps.f0xx.org (Gentoo FE, TLS)
|
||||||
|
→ http://artc0.intra.raptor.org:80 ← this vhost only
|
||||||
|
→ /etc/nginx/conf.d/apps.conf listen 80
|
||||||
|
```
|
||||||
|
|
||||||
|
**8089 is not this installation** (Janus/other). Do not point androidcast FE locations at :8089.
|
||||||
|
|
||||||
|
On FE, hub and (if you fix it) crashes should both target **`http://artc0.intra.raptor.org:80`**, same as `location /`.
|
||||||
|
|
||||||
|
## 1. `index.html`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .../android_cast/examples/app_hub/index.html \
|
||||||
|
.../htdocs/apps/app/androidcast_project/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. BE nginx
|
||||||
|
|
||||||
|
Edit **`/etc/nginx/conf.d/apps.conf`** on Alpine (sshfs: `tmp/BE_alpine/etc/nginx/...`).
|
||||||
|
|
||||||
|
Replace the whole **`server { listen 80; ... }`** block with:
|
||||||
|
|
||||||
|
**`examples/crash_reporter/backend/nginx.apps-port80.fragment`**
|
||||||
|
|
||||||
|
That file has hub + crashes + assets on **port 80**.
|
||||||
|
|
||||||
|
The **`listen 443 ssl`** block can stay for direct HTTPS to the VM if you use it internally; public traffic from FE never needs it for this app.
|
||||||
|
|
||||||
|
Use prefix location for the hub (not `alias` to a single file):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /app/androidcast_project/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Reload BE
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh alpine-be 'sudo nginx -t && sudo rc-service nginx reload'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" -H "Host: apps.f0xx.org" \
|
||||||
|
"http://artc0.intra.raptor.org/app/androidcast_project/"
|
||||||
|
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||||
|
"https://apps.f0xx.org/app/androidcast_project/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## FE (optional, not required if `location /` already proxies to :80)
|
||||||
|
|
||||||
|
If crashes on FE still say `proxy_pass https://artc0...:8089/.../crashes/`, change to:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_pass http://artc0.intra.raptor.org/app/androidcast_project/crashes/;
|
||||||
|
```
|
||||||
|
|
||||||
|
(same upstream as the hub — port **80**, no TLS to BE from FE)
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
# Project hub
|
# Project hub
|
||||||
|
|
||||||
```bash
|
**Infra overview:** [docs/INFRA.md](../../docs/INFRA.md)
|
||||||
cp examples/app_hub/index.html /var/www/localhost/htdocs/apps/app/androidcast_project/
|
|
||||||
```
|
|
||||||
|
|
||||||
Nginx (`nginx.apps.conf.fragment`): serve that directory — **not** `alias …/index.html` (that causes 502).
|
**BE only.** FE `apps.f0xx.org` sends `location /` → `http://artc0.intra.raptor.org:80` (not :8089).
|
||||||
|
|
||||||
```bash
|
See **[DEPLOY.md](DEPLOY.md)** — edit `listen 80` in `tmp/BE_alpine/etc/nginx/conf.d/apps.conf` using
|
||||||
nginx -t && rc-service nginx reload
|
`examples/crash_reporter/backend/nginx.apps-port80.fragment`.
|
||||||
```
|
|
||||||
|
|||||||
15
examples/app_hub/deploy/BE-apps-hub.conf.snippet
Normal file
15
examples/app_hub/deploy/BE-apps-hub.conf.snippet
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Replace the broken hub block in /etc/nginx/conf.d/apps.conf on Alpine BE.
|
||||||
|
# Remove duplicate "return 301 .../crashes/" and alias-to-index.html lines.
|
||||||
|
|
||||||
|
location = /app/androidcast_project {
|
||||||
|
return 301 /app/androidcast_project/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /app/androidcast_project/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||||
|
}
|
||||||
@@ -94,6 +94,9 @@ curl -X POST http://127.0.0.1:8080/api/upload.php \
|
|||||||
|
|
||||||
## Production (nginx + PHP-FPM)
|
## Production (nginx + PHP-FPM)
|
||||||
|
|
||||||
|
**Deploy topology (FE/BE, ports, sshfs):** [docs/INFRA.md](../../../docs/INFRA.md).
|
||||||
|
**Canonical BE vhost for FE traffic:** `nginx.apps-port80.fragment` (`listen 80`). Port **8089** is not androidcast.
|
||||||
|
|
||||||
0. Ensure PHP modules above are installed for the FPM SAPI (not only CLI `php -m`).
|
0. Ensure PHP modules above are installed for the FPM SAPI (not only CLI `php -m`).
|
||||||
1. Deploy the git tree so this path exists on the VM (no symlinks required):
|
1. Deploy the git tree so this path exists on the VM (no symlinks required):
|
||||||
`.../androidcast_project/android_cast/examples/crash_reporter/backend/`
|
`.../androidcast_project/android_cast/examples/crash_reporter/backend/`
|
||||||
@@ -187,6 +190,10 @@ php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/andro
|
|||||||
|
|
||||||
MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql` or `./scripts/migrate-003-tickets.sh`. Fresh installs: included in `sql/schema.mariadb.sql` and `scripts/init-mariadb.sh`.
|
MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql` or `./scripts/migrate-003-tickets.sh`. Fresh installs: included in `sql/schema.mariadb.sql` and `scripts/init-mariadb.sh`.
|
||||||
|
|
||||||
|
**Workflow / assignees / comments (004):** `mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql` — see [docs/TICKETS_ROADMAP.md](../../../docs/TICKETS_ROADMAP.md).
|
||||||
|
|
||||||
|
**Attachments (005):** `mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql` — files + external links (Google URLs auto-labeled).
|
||||||
|
|
||||||
## Default accounts
|
## Default accounts
|
||||||
|
|
||||||
| User | Password | Role |
|
| User | Password | Role |
|
||||||
|
|||||||
47
examples/crash_reporter/backend/nginx.apps-port80.fragment
Normal file
47
examples/crash_reporter/backend/nginx.apps-port80.fragment
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Full androidcast vhost for Alpine BE — listen 80 only.
|
||||||
|
# FE (apps.f0xx.org) proxies here: proxy_pass http://artc0.intra.raptor.org:80;
|
||||||
|
# Port 8089 is a different service (e.g. Janus) — not this vhost.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location = /app/androidcast_project {
|
||||||
|
return 301 /app/androidcast_project/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /app/androidcast_project/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/crashes {
|
||||||
|
return 301 /app/androidcast_project/crashes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/crashes/api/upload.php {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
include /etc/nginx/fastcgi.conf;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
return 301 /app/androidcast_project/;
|
return 301 /app/androidcast_project/;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static hub: one index.html in project dir (directory alias — not alias-to-file)
|
# Hub — live vhost is listen 80 on BE (see nginx.apps-port80.fragment). Not :8089.
|
||||||
location = /app/androidcast_project/ {
|
location /app/androidcast_project/ {
|
||||||
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/;
|
||||||
index index.html;
|
index index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if (!Auth::user()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo 'Unauthorized';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
http_response_code(503);
|
||||||
|
echo 'Tickets unavailable';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$att = TicketAttachmentRepository::getById($id);
|
||||||
|
if (!$att || ($att['kind'] ?? '') !== 'file') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = TicketAttachmentRepository::filePath($att);
|
||||||
|
if ($path === null) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = (string) ($att['original_name'] ?? 'download');
|
||||||
|
$mime = (string) ($att['mime_type'] ?? 'application/octet-stream');
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . (string) filesize($path));
|
||||||
|
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
readfile($path);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if (!Auth::user()) {
|
||||||
|
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
json_out(['ok' => true, 'attachments' => TicketAttachmentRepository::listForTicket($ticketId)]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'ticket_attachments') || str_contains($e->getMessage(), 'no such table')) {
|
||||||
|
json_out(['ok' => false, 'error' => 'attachments_table_missing', 'hint' => 'Run sql/migrations/005_ticket_attachments.sql'], 503);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TicketAttachmentRepository::delete($id, (string) ($user['username'] ?? ''));
|
||||||
|
json_out(['ok' => true]);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$code = $e->getMessage() === 'forbidden' ? 403 : 404;
|
||||||
|
json_out(['ok' => false, 'error' => $e->getMessage()], $code);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'ticket_attachments')) {
|
||||||
|
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = (int) ($_POST['ticket_id'] ?? 0);
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
|
||||||
|
if (is_array($data)) {
|
||||||
|
$ticketId = (int) ($data['ticket_id'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||||
|
}
|
||||||
|
// Any logged-in user who can view the ticket may add attachments (same as comments).
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!empty($_FILES['file']['name'])) {
|
||||||
|
$att = TicketAttachmentRepository::addFile(
|
||||||
|
$ticketId,
|
||||||
|
(string) ($user['username'] ?? ''),
|
||||||
|
$_FILES['file'],
|
||||||
|
trim((string) ($_POST['title'] ?? ''))
|
||||||
|
);
|
||||||
|
json_out(['ok' => true, 'attachment' => $att]);
|
||||||
|
}
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||||
|
if (!is_array($data)) {
|
||||||
|
json_out(['ok' => false, 'error' => 'file or url required'], 400);
|
||||||
|
}
|
||||||
|
$url = trim((string) ($data['url'] ?? $data['external_url'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
json_out(['ok' => false, 'error' => 'url required'], 400);
|
||||||
|
}
|
||||||
|
$att = TicketAttachmentRepository::addLink(
|
||||||
|
$ticketId,
|
||||||
|
(string) ($user['username'] ?? ''),
|
||||||
|
$url,
|
||||||
|
trim((string) ($data['title'] ?? ''))
|
||||||
|
);
|
||||||
|
json_out(['ok' => true, 'attachment' => $att]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'ticket_attachments')) {
|
||||||
|
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
|
||||||
|
}
|
||||||
|
error_log('ticket_attachments: ' . $e->getMessage());
|
||||||
|
json_out(['ok' => false, 'error' => 'save failed'], 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if (!Auth::user()) {
|
||||||
|
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Database::requireTicketsTable();
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||||
|
}
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
json_out(['ok' => true, 'comments' => TicketCommentRepository::listForTicket($ticketId)]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
|
||||||
|
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||||
|
if (!is_array($data)) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = (int) ($data['ticket_id'] ?? 0);
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||||
|
}
|
||||||
|
if (empty($ticket['can_edit']) && !Auth::canEditTags()) {
|
||||||
|
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$comment = TicketCommentRepository::add(
|
||||||
|
$ticketId,
|
||||||
|
(string) ($user['username'] ?? ''),
|
||||||
|
(string) ($data['body'] ?? '')
|
||||||
|
);
|
||||||
|
json_out(['ok' => true, 'comment' => $comment]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
|
||||||
|
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
|
||||||
|
}
|
||||||
|
error_log('ticket_comments: ' . $e->getMessage());
|
||||||
|
json_out(['ok' => false, 'error' => 'save failed'], 500);
|
||||||
|
}
|
||||||
@@ -35,11 +35,14 @@ if (empty($ticket['can_edit'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$fields = [];
|
$fields = [];
|
||||||
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username'] as $key) {
|
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username', 'workflow_state'] as $key) {
|
||||||
if (array_key_exists($key, $data)) {
|
if (array_key_exists($key, $data)) {
|
||||||
$fields[$key] = $data[$key];
|
$fields[$key] = $data[$key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('assignees', $data)) {
|
||||||
|
$fields['assignees'] = $data['assignees'];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
TicketRepository::update($id, $fields);
|
TicketRepository::update($id, $fields);
|
||||||
|
|||||||
9
examples/crash_reporter/backend/public/api/users.php
Normal file
9
examples/crash_reporter/backend/public/api/users.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if (!Auth::user()) {
|
||||||
|
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]);
|
||||||
@@ -239,6 +239,46 @@ a:hover { text-decoration: underline; }
|
|||||||
.ticket-issue-title { font-weight: 600; }
|
.ticket-issue-title { font-weight: 600; }
|
||||||
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
|
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
|
||||||
.ticket-col-env { font-size: 0.9em; max-width: 220px; }
|
.ticket-col-env { font-size: 0.9em; max-width: 220px; }
|
||||||
|
.assignee-editor { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||||
|
.assignee-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px; border-radius: 999px; background: var(--surface-2, #2a3344);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.assignee-remove {
|
||||||
|
border: none; background: transparent; color: var(--muted, #94a3b8);
|
||||||
|
cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.assignee-add-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.ticket-attachments { margin-top: 24px; }
|
||||||
|
.ticket-attachment-list { list-style: none; padding: 0; margin: 0 0 16px; }
|
||||||
|
.ticket-attachment {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
|
||||||
|
padding: 10px 12px; margin-bottom: 8px; border-radius: 8px;
|
||||||
|
background: var(--surface-2, #1e293b);
|
||||||
|
}
|
||||||
|
.attachment-provider {
|
||||||
|
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
}
|
||||||
|
.ticket-attachment-add {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ticket-attachment-add { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.btn-small { padding: 2px 8px; font-size: 0.85rem; min-height: auto; }
|
||||||
|
.ticket-comments { margin-top: 24px; }
|
||||||
|
.ticket-comment-list { list-style: none; padding: 0; margin: 0 0 16px; }
|
||||||
|
.ticket-comment {
|
||||||
|
padding: 12px 14px; margin-bottom: 10px; border-radius: 8px;
|
||||||
|
background: var(--surface-2, #1e293b);
|
||||||
|
}
|
||||||
|
.ticket-comment-meta { margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.ticket-comment-body {
|
||||||
|
margin: 0; white-space: pre-wrap; font-family: inherit; font-size: 0.95rem;
|
||||||
|
background: transparent; border: none; padding: 0;
|
||||||
|
}
|
||||||
.ticket-edit-form { display: grid; grid-template-columns: 1fr min(320px, 100%); gap: 16px; align-items: start; }
|
.ticket-edit-form { display: grid; grid-template-columns: 1fr min(320px, 100%); gap: 16px; align-items: start; }
|
||||||
.ticket-edit-form .card--wide { grid-column: 1 / -1; }
|
.ticket-edit-form .card--wide { grid-column: 1 / -1; }
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
|||||||
@@ -139,5 +139,20 @@
|
|||||||
"ticket.created": "Created",
|
"ticket.created": "Created",
|
||||||
"ticket.updated": "Updated",
|
"ticket.updated": "Updated",
|
||||||
"ticket.save": "Save ticket",
|
"ticket.save": "Save ticket",
|
||||||
"ticket.source": "Source / context"
|
"ticket.source": "Source / context",
|
||||||
|
"ticket.lifecycle": "Lifecycle",
|
||||||
|
"ticket.assignees": "Assignees",
|
||||||
|
"ticket.assignee_add": "Add assignee",
|
||||||
|
"ticket.comments": "Comments",
|
||||||
|
"ticket.comment_add": "Add comment",
|
||||||
|
"ticket.comment_post": "Post comment",
|
||||||
|
"ticket.attachments": "Attachments",
|
||||||
|
"ticket.attach_link": "Add link",
|
||||||
|
"ticket.attach_link_hint": "Google Docs, Sheets, Drive, Meet, or any HTTPS URL.",
|
||||||
|
"ticket.attach_link_btn": "Attach link",
|
||||||
|
"ticket.attach_file": "Upload file",
|
||||||
|
"ticket.attach_file_hint": "Max 16 MiB — images, logs, pdf, zip, …",
|
||||||
|
"ticket.attach_file_btn": "Upload",
|
||||||
|
"ticket.link_title": "Title (optional)",
|
||||||
|
"ticket.file_pick": "File"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,5 +115,21 @@
|
|||||||
"login.register": "Регистрация",
|
"login.register": "Регистрация",
|
||||||
"login.register_soon": "(скоро)",
|
"login.register_soon": "(скоро)",
|
||||||
"login.error": "Неверные учётные данные",
|
"login.error": "Неверные учётные данные",
|
||||||
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
|
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
|
||||||
|
"nav.tickets": "Тикеты",
|
||||||
|
"ticket.lifecycle": "Жизненный цикл",
|
||||||
|
"ticket.assignees": "Исполнители",
|
||||||
|
"ticket.assignee_add": "Добавить",
|
||||||
|
"ticket.comments": "Комментарии",
|
||||||
|
"ticket.comment_add": "Новый комментарий",
|
||||||
|
"ticket.comment_post": "Отправить",
|
||||||
|
"ticket.attachments": "Вложения",
|
||||||
|
"ticket.attach_link": "Ссылка",
|
||||||
|
"ticket.attach_link_hint": "Google Docs, Таблицы, Drive, Meet или любой HTTPS URL.",
|
||||||
|
"ticket.attach_link_btn": "Прикрепить",
|
||||||
|
"ticket.attach_file": "Файл",
|
||||||
|
"ticket.attach_file_hint": "До 16 МБ — изображения, логи, pdf, zip…",
|
||||||
|
"ticket.attach_file_btn": "Загрузить",
|
||||||
|
"ticket.link_title": "Название (необяз.)",
|
||||||
|
"ticket.file_pick": "Файл"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1461,6 +1461,297 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
const tree = document.getElementById('detail-tree');
|
const tree = document.getElementById('detail-tree');
|
||||||
if (tree) bindDetailTree(tree);
|
if (tree) bindDetailTree(tree);
|
||||||
|
|
||||||
|
let assignees = [];
|
||||||
|
const assigneeDataEl = document.getElementById('ticket-assignees-data');
|
||||||
|
if (assigneeDataEl) {
|
||||||
|
try {
|
||||||
|
assignees = JSON.parse(assigneeDataEl.textContent || '[]');
|
||||||
|
if (!Array.isArray(assignees)) assignees = [];
|
||||||
|
} catch {
|
||||||
|
assignees = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assigneeEditor = document.getElementById('assignee-editor');
|
||||||
|
const assigneePick = document.getElementById('assignee-user-pick');
|
||||||
|
const assigneeAddBtn = document.getElementById('assignee-add-btn');
|
||||||
|
|
||||||
|
function renderAssignees() {
|
||||||
|
if (!assigneeEditor) return;
|
||||||
|
if (!assignees.length) {
|
||||||
|
assigneeEditor.innerHTML = '<span class="muted">—</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assigneeEditor.innerHTML = assignees
|
||||||
|
.map(function (a, idx) {
|
||||||
|
const role = a.role ? ' <span class="muted">(' + escapeHtml(a.role) + ')</span>' : '';
|
||||||
|
return (
|
||||||
|
'<span class="assignee-pill">' +
|
||||||
|
escapeHtml(a.username) +
|
||||||
|
role +
|
||||||
|
' <button type="button" class="assignee-remove" data-idx="' +
|
||||||
|
idx +
|
||||||
|
'" aria-label="Remove">×</button></span>'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
assigneeEditor.querySelectorAll('.assignee-remove').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const i = Number(btn.getAttribute('data-idx'));
|
||||||
|
assignees.splice(i, 1);
|
||||||
|
renderAssignees();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsersForAssignees() {
|
||||||
|
if (!assigneePick) return;
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', basePath() + '/api/users.php', true);
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.ok || !Array.isArray(data.users)) return;
|
||||||
|
data.users.forEach(function (u) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = u.username;
|
||||||
|
opt.textContent = u.username + (u.role ? ' (' + u.role + ')' : '');
|
||||||
|
opt.dataset.role = u.role || '';
|
||||||
|
assigneePick.appendChild(opt);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigneeEditor) {
|
||||||
|
renderAssignees();
|
||||||
|
loadUsersForAssignees();
|
||||||
|
if (assigneeAddBtn && assigneePick) {
|
||||||
|
assigneeAddBtn.addEventListener('click', function () {
|
||||||
|
const username = assigneePick.value;
|
||||||
|
if (!username) return;
|
||||||
|
const role = assigneePick.selectedOptions[0]?.dataset.role || '';
|
||||||
|
if (assignees.some(function (a) { return a.username === username; })) return;
|
||||||
|
assignees.push({ username: username, role: role });
|
||||||
|
renderAssignees();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentList = document.getElementById('ticket-attachment-list');
|
||||||
|
const linkForm = document.getElementById('ticket-link-form');
|
||||||
|
const fileForm = document.getElementById('ticket-file-form');
|
||||||
|
const linkStatus = document.getElementById('ticket-link-status');
|
||||||
|
const fileStatus = document.getElementById('ticket-file-status');
|
||||||
|
|
||||||
|
function renderAttachmentItem(att) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'ticket-attachment ticket-attachment--' + (att.kind || 'file');
|
||||||
|
li.dataset.attachmentId = String(att.id);
|
||||||
|
let main = '';
|
||||||
|
const label = escapeHtml(att.provider_label || 'File');
|
||||||
|
if (att.kind === 'link' && att.external_url) {
|
||||||
|
main =
|
||||||
|
'<a href="' +
|
||||||
|
escapeHtml(att.external_url) +
|
||||||
|
'" target="_blank" rel="noopener noreferrer">' +
|
||||||
|
escapeHtml(att.title || att.external_url) +
|
||||||
|
'</a>';
|
||||||
|
} else if (att.download_url) {
|
||||||
|
const size =
|
||||||
|
att.size_bytes > 0
|
||||||
|
? ' <span class="muted">(' + (att.size_bytes / 1024).toFixed(1) + ' KB)</span>'
|
||||||
|
: '';
|
||||||
|
main =
|
||||||
|
'<a href="' +
|
||||||
|
escapeHtml(att.download_url) +
|
||||||
|
'">' +
|
||||||
|
escapeHtml(att.title || att.original_name || 'file') +
|
||||||
|
'</a>' +
|
||||||
|
size;
|
||||||
|
} else {
|
||||||
|
main = '<span>' + escapeHtml(att.title || '') + '</span>';
|
||||||
|
}
|
||||||
|
li.innerHTML =
|
||||||
|
'<span class="attachment-provider">' +
|
||||||
|
label +
|
||||||
|
'</span> ' +
|
||||||
|
main +
|
||||||
|
' <span class="muted attachment-meta">' +
|
||||||
|
escapeHtml(att.created_by || '') +
|
||||||
|
'</span>' +
|
||||||
|
' <button type="button" class="btn btn-small attachment-remove" data-id="' +
|
||||||
|
att.id +
|
||||||
|
'">×</button>';
|
||||||
|
li.querySelector('.attachment-remove').addEventListener('click', function () {
|
||||||
|
if (!confirm('Remove attachment?')) return;
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + att.id, true);
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.ok) li.remove();
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkForm) {
|
||||||
|
linkForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(linkForm);
|
||||||
|
const url = String(fd.get('url') || '').trim();
|
||||||
|
if (!url) return;
|
||||||
|
if (linkStatus) linkStatus.textContent = 'Saving…';
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', basePath() + '/api/ticket_attachments.php', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
if (linkStatus) linkStatus.textContent = 'Invalid response';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
if (linkStatus) linkStatus.textContent = data.error || 'Failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachmentList && data.attachment) {
|
||||||
|
attachmentList.appendChild(renderAttachmentItem(data.attachment));
|
||||||
|
}
|
||||||
|
linkForm.reset();
|
||||||
|
if (linkStatus) linkStatus.textContent = 'Attached';
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
if (linkStatus) linkStatus.textContent = t('reports.network_error');
|
||||||
|
};
|
||||||
|
xhr.send(
|
||||||
|
JSON.stringify({
|
||||||
|
ticket_id: Number(id),
|
||||||
|
title: fd.get('title'),
|
||||||
|
url: url,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileForm) {
|
||||||
|
fileForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(fileForm);
|
||||||
|
fd.append('ticket_id', id);
|
||||||
|
if (fileStatus) fileStatus.textContent = 'Uploading…';
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', basePath() + '/api/ticket_attachments.php', true);
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
if (fileStatus) fileStatus.textContent = 'Invalid response';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
if (fileStatus) fileStatus.textContent = data.error || 'Failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attachmentList && data.attachment) {
|
||||||
|
attachmentList.appendChild(renderAttachmentItem(data.attachment));
|
||||||
|
}
|
||||||
|
fileForm.reset();
|
||||||
|
if (fileStatus) fileStatus.textContent = 'Uploaded';
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
if (fileStatus) fileStatus.textContent = t('reports.network_error');
|
||||||
|
};
|
||||||
|
xhr.send(fd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentList) {
|
||||||
|
attachmentList.querySelectorAll('.attachment-remove').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const attId = btn.getAttribute('data-id');
|
||||||
|
if (!confirm('Remove attachment?')) return;
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + attId, true);
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.ok) btn.closest('.ticket-attachment')?.remove();
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentForm = document.getElementById('ticket-comment-form');
|
||||||
|
const commentStatus = document.getElementById('ticket-comment-status');
|
||||||
|
const commentList = document.getElementById('ticket-comment-list');
|
||||||
|
if (commentForm) {
|
||||||
|
commentForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(commentForm);
|
||||||
|
const bodyText = String(fd.get('body') || '').trim();
|
||||||
|
if (!bodyText) return;
|
||||||
|
if (commentStatus) commentStatus.textContent = 'Posting…';
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', basePath() + '/api/ticket_comments.php', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onload = function () {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch {
|
||||||
|
if (commentStatus) commentStatus.textContent = 'Invalid response';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
if (commentStatus) commentStatus.textContent = data.error || 'Failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = data.comment;
|
||||||
|
if (commentList && c) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'ticket-comment';
|
||||||
|
const when = c.created_at_ms
|
||||||
|
? new Date(Number(c.created_at_ms)).toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
: '';
|
||||||
|
li.innerHTML =
|
||||||
|
'<div class="ticket-comment-meta"><strong>' +
|
||||||
|
escapeHtml(c.author_username) +
|
||||||
|
'</strong> <span class="muted">' +
|
||||||
|
escapeHtml(when) +
|
||||||
|
'</span></div><pre class="ticket-comment-body"></pre>';
|
||||||
|
li.querySelector('.ticket-comment-body').textContent = c.body;
|
||||||
|
commentList.appendChild(li);
|
||||||
|
}
|
||||||
|
commentForm.reset();
|
||||||
|
if (commentStatus) commentStatus.textContent = 'Posted';
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
if (commentStatus) commentStatus.textContent = t('reports.network_error');
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify({ ticket_id: Number(id), body: bodyText }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const form = document.getElementById('ticket-edit-form');
|
const form = document.getElementById('ticket-edit-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const statusEl = document.getElementById('ticket-save-status');
|
const statusEl = document.getElementById('ticket-save-status');
|
||||||
@@ -1488,6 +1779,8 @@
|
|||||||
owner_username: fd.get('owner_username'),
|
owner_username: fd.get('owner_username'),
|
||||||
verified_by_username: fd.get('verified_by_username'),
|
verified_by_username: fd.get('verified_by_username'),
|
||||||
rating: Number(fd.get('rating')),
|
rating: Number(fd.get('rating')),
|
||||||
|
workflow_state: fd.get('workflow_state'),
|
||||||
|
assignees: assignees,
|
||||||
};
|
};
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
|
xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
|
||||||
|
|||||||
@@ -65,6 +65,26 @@ if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_up
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/ticket_comments.php' || str_ends_with($route, '/api/ticket_comments.php')) {
|
||||||
|
require __DIR__ . '/api/ticket_comments.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/users.php' || str_ends_with($route, '/api/users.php')) {
|
||||||
|
require __DIR__ . '/api/users.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/ticket_attachments.php')) {
|
||||||
|
require __DIR__ . '/api/ticket_attachments.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
|
||||||
|
require __DIR__ . '/api/ticket_attachment.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/logout') {
|
if ($route === '/logout') {
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
header('Location: ' . $base . '/login');
|
header('Location: ' . $base . '/login');
|
||||||
|
|||||||
14
examples/crash_reporter/backend/scripts/deploy-to-be-mount.sh
Executable file
14
examples/crash_reporter/backend/scripts/deploy-to-be-mount.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sync backend code to BE sshfs mount (skip data/ — permissions).
|
||||||
|
# Usage: ./scripts/deploy-to-be-mount.sh [DEST]
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DEST="${1:-$(cd "$SRC/../../../tmp/BE_alpine/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend" 2>/dev/null && pwd || true)}"
|
||||||
|
if [[ -z "$DEST" || ! -d "$DEST" ]]; then
|
||||||
|
echo "DEST not found. Pass BE mount path as first argument." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rsync -a --no-group --no-owner \
|
||||||
|
--exclude 'data/' --exclude 'config/config.php' \
|
||||||
|
"$SRC/" "$DEST/"
|
||||||
|
echo "Synced to $DEST (config.php and data/ untouched)."
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Ticket workflow, assignees, comments. Run as MySQL root on existing DBs.
|
||||||
|
-- mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql
|
||||||
|
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD COLUMN workflow_state VARCHAR(32) NOT NULL DEFAULT 'triage' AFTER tags_json,
|
||||||
|
ADD COLUMN assignees_json LONGTEXT NOT NULL DEFAULT '[]' AFTER workflow_state;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_comments (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
author_username VARCHAR(64) NOT NULL,
|
||||||
|
body LONGTEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_ticket_comments_ticket (ticket_id),
|
||||||
|
KEY idx_ticket_comments_created (created_at_ms),
|
||||||
|
CONSTRAINT fk_ticket_comments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE tickets ADD COLUMN workflow_state TEXT NOT NULL DEFAULT 'triage';
|
||||||
|
ALTER TABLE tickets ADD COLUMN assignees_json TEXT NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
author_username TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket ON ticket_comments (ticket_id);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Ticket attachments: uploaded files + external links (incl. Google URLs).
|
||||||
|
-- mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_attachments (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
kind ENUM('file', 'link') NOT NULL,
|
||||||
|
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
external_url TEXT NULL,
|
||||||
|
provider VARCHAR(32) NOT NULL DEFAULT 'generic',
|
||||||
|
storage_name VARCHAR(128) NULL,
|
||||||
|
original_name VARCHAR(256) NULL,
|
||||||
|
mime_type VARCHAR(128) NULL,
|
||||||
|
size_bytes BIGINT UNSIGNED NULL,
|
||||||
|
created_by VARCHAR(64) NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_ticket_attachments_ticket (ticket_id),
|
||||||
|
KEY idx_ticket_attachments_created (created_at_ms),
|
||||||
|
CONSTRAINT fk_ticket_attachments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ticket_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('file', 'link')),
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
external_url TEXT NULL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'generic',
|
||||||
|
storage_name TEXT NULL,
|
||||||
|
original_name TEXT NULL,
|
||||||
|
mime_type TEXT NULL,
|
||||||
|
size_bytes INTEGER NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_attachments_ticket ON ticket_attachments (ticket_id);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Classify external URLs (Google Suite labels now; OAuth/embed later). */
|
||||||
|
final class TicketAttachmentProvider {
|
||||||
|
public const GENERIC = 'generic';
|
||||||
|
public const GOOGLE_DOCS = 'google_docs';
|
||||||
|
public const GOOGLE_SHEETS = 'google_sheets';
|
||||||
|
public const GOOGLE_SLIDES = 'google_slides';
|
||||||
|
public const GOOGLE_DRIVE = 'google_drive';
|
||||||
|
public const GOOGLE_MEET = 'google_meet';
|
||||||
|
public const GOOGLE_CHAT = 'google_chat';
|
||||||
|
public const GOOGLE_FORMS = 'google_forms';
|
||||||
|
|
||||||
|
/** @return array{id:string,label:string}> */
|
||||||
|
public static function labels(): array {
|
||||||
|
return [
|
||||||
|
self::GENERIC => ['id' => self::GENERIC, 'label' => 'Link'],
|
||||||
|
self::GOOGLE_DOCS => ['id' => self::GOOGLE_DOCS, 'label' => 'Google Docs'],
|
||||||
|
self::GOOGLE_SHEETS => ['id' => self::GOOGLE_SHEETS, 'label' => 'Google Sheets'],
|
||||||
|
self::GOOGLE_SLIDES => ['id' => self::GOOGLE_SLIDES, 'label' => 'Google Slides'],
|
||||||
|
self::GOOGLE_DRIVE => ['id' => self::GOOGLE_DRIVE, 'label' => 'Google Drive'],
|
||||||
|
self::GOOGLE_MEET => ['id' => self::GOOGLE_MEET, 'label' => 'Google Meet'],
|
||||||
|
self::GOOGLE_CHAT => ['id' => self::GOOGLE_CHAT, 'label' => 'Google Chat'],
|
||||||
|
self::GOOGLE_FORMS => ['id' => self::GOOGLE_FORMS, 'label' => 'Google Forms'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function labelFor(string $provider): string {
|
||||||
|
$labels = self::labels();
|
||||||
|
return $labels[$provider]['label'] ?? 'Link';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function detectFromUrl(string $url): string {
|
||||||
|
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
|
||||||
|
if ($host === '') {
|
||||||
|
return self::GENERIC;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'docs.google.com')) {
|
||||||
|
return self::GOOGLE_DOCS;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'sheets.google.com')) {
|
||||||
|
return self::GOOGLE_SHEETS;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'slides.google.com')) {
|
||||||
|
return self::GOOGLE_SLIDES;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'drive.google.com')) {
|
||||||
|
return self::GOOGLE_DRIVE;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'meet.google.com')) {
|
||||||
|
return self::GOOGLE_MEET;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'chat.google.com')) {
|
||||||
|
return self::GOOGLE_CHAT;
|
||||||
|
}
|
||||||
|
if (str_contains($host, 'forms.gle') || str_contains($host, 'forms.google.com')) {
|
||||||
|
return self::GOOGLE_FORMS;
|
||||||
|
}
|
||||||
|
return self::GENERIC;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class TicketAttachmentRepository {
|
||||||
|
private const MAX_BYTES = 16777216; // 16 MiB
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private static function allowedExtensions(): array {
|
||||||
|
return [
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg',
|
||||||
|
'pdf', 'txt', 'log', 'md', 'json', 'xml', 'zip',
|
||||||
|
'mp4', 'webm', 'mp3', 'wav',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function storageRoot(): string {
|
||||||
|
$dir = dirname(__DIR__) . '/storage/ticket_attachments';
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function listForTicket(int $ticketId): array {
|
||||||
|
if (!TicketRepository::getById($ticketId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
|
||||||
|
);
|
||||||
|
$stmt->execute([$ticketId]);
|
||||||
|
$bp = Auth::basePath();
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[] = self::mapRow($row, $bp);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getById(int $id): ?array {
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$ticket = TicketRepository::getById((int) $row['ticket_id']);
|
||||||
|
if (!$ticket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self::mapRow($row, Auth::basePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function addLink(int $ticketId, string $author, string $url, string $title = ''): array {
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
throw new RuntimeException('not found');
|
||||||
|
}
|
||||||
|
$url = trim($url);
|
||||||
|
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
throw new InvalidArgumentException('valid url required');
|
||||||
|
}
|
||||||
|
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||||
|
throw new InvalidArgumentException('http or https url required');
|
||||||
|
}
|
||||||
|
$provider = TicketAttachmentProvider::detectFromUrl($url);
|
||||||
|
if ($title === '') {
|
||||||
|
$title = TicketAttachmentProvider::labelFor($provider);
|
||||||
|
}
|
||||||
|
$now = (int) round(microtime(true) * 1000);
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider,
|
||||||
|
storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms)
|
||||||
|
VALUES (?, \'link\', ?, ?, ?, NULL, NULL, NULL, NULL, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$ticketId,
|
||||||
|
mb_substr($title, 0, 256),
|
||||||
|
$url,
|
||||||
|
$provider,
|
||||||
|
mb_substr(trim($author), 0, 64),
|
||||||
|
$now,
|
||||||
|
]);
|
||||||
|
self::touchTicket($ticketId, $now);
|
||||||
|
return self::getById((int) $pdo->lastInsertId()) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{name:string,type:string,tmp_name:string,error:int,size:int} $file */
|
||||||
|
public static function addFile(int $ticketId, string $author, array $file, string $title = ''): array {
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
throw new RuntimeException('not found');
|
||||||
|
}
|
||||||
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
throw new InvalidArgumentException('upload failed');
|
||||||
|
}
|
||||||
|
$size = (int) ($file['size'] ?? 0);
|
||||||
|
if ($size <= 0 || $size > self::MAX_BYTES) {
|
||||||
|
throw new InvalidArgumentException('file too large (max 16 MiB)');
|
||||||
|
}
|
||||||
|
$original = basename((string) ($file['name'] ?? 'file'));
|
||||||
|
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
||||||
|
if ($ext === '' || !in_array($ext, self::allowedExtensions(), true)) {
|
||||||
|
throw new InvalidArgumentException('file type not allowed');
|
||||||
|
}
|
||||||
|
$mime = (string) ($file['type'] ?? 'application/octet-stream');
|
||||||
|
$storageName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||||
|
$dir = self::storageRoot() . '/' . $ticketId;
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
$dest = $dir . '/' . $storageName;
|
||||||
|
if (!move_uploaded_file((string) $file['tmp_name'], $dest)) {
|
||||||
|
throw new RuntimeException('could not store file');
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
$title = $original;
|
||||||
|
}
|
||||||
|
$now = (int) round(microtime(true) * 1000);
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO ticket_attachments (ticket_id, kind, title, external_url, provider,
|
||||||
|
storage_name, original_name, mime_type, size_bytes, created_by, created_at_ms)
|
||||||
|
VALUES (?, \'file\', ?, NULL, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$ticketId,
|
||||||
|
mb_substr($title, 0, 256),
|
||||||
|
TicketAttachmentProvider::GENERIC,
|
||||||
|
$storageName,
|
||||||
|
mb_substr($original, 0, 256),
|
||||||
|
mb_substr($mime, 0, 128),
|
||||||
|
$size,
|
||||||
|
mb_substr(trim($author), 0, 64),
|
||||||
|
$now,
|
||||||
|
]);
|
||||||
|
self::touchTicket($ticketId, $now);
|
||||||
|
return self::getById((int) $pdo->lastInsertId()) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id, string $username): void {
|
||||||
|
$att = self::getById($id);
|
||||||
|
if (!$att) {
|
||||||
|
throw new RuntimeException('not found');
|
||||||
|
}
|
||||||
|
$ticket = TicketRepository::getById((int) $att['ticket_id']);
|
||||||
|
if (!$ticket) {
|
||||||
|
throw new RuntimeException('not found');
|
||||||
|
}
|
||||||
|
$can = !empty($ticket['can_edit'])
|
||||||
|
|| strcasecmp((string) ($att['created_by'] ?? ''), $username) === 0
|
||||||
|
|| Auth::canEditTags();
|
||||||
|
if (!$can) {
|
||||||
|
throw new RuntimeException('forbidden');
|
||||||
|
}
|
||||||
|
if ($att['kind'] === 'file' && !empty($att['storage_name'])) {
|
||||||
|
$path = self::storageRoot() . '/' . $att['ticket_id'] . '/' . $att['storage_name'];
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([$id]);
|
||||||
|
self::touchTicket((int) $att['ticket_id'], (int) round(microtime(true) * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function filePath(array $attachment): ?string {
|
||||||
|
if (($attachment['kind'] ?? '') !== 'file' || empty($attachment['storage_name'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$path = self::storageRoot() . '/' . (int) $attachment['ticket_id'] . '/' . $attachment['storage_name'];
|
||||||
|
return is_file($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function touchTicket(int $ticketId, int $now): void {
|
||||||
|
Database::pdo()->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $row */
|
||||||
|
private static function mapRow(array $row, string $basePath): array {
|
||||||
|
$id = (int) $row['id'];
|
||||||
|
$kind = (string) $row['kind'];
|
||||||
|
$provider = (string) ($row['provider'] ?? TicketAttachmentProvider::GENERIC);
|
||||||
|
$item = [
|
||||||
|
'id' => $id,
|
||||||
|
'ticket_id' => (int) $row['ticket_id'],
|
||||||
|
'kind' => $kind,
|
||||||
|
'title' => (string) ($row['title'] ?? ''),
|
||||||
|
'external_url' => (string) ($row['external_url'] ?? ''),
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_label' => TicketAttachmentProvider::labelFor($provider),
|
||||||
|
'original_name' => (string) ($row['original_name'] ?? ''),
|
||||||
|
'mime_type' => (string) ($row['mime_type'] ?? ''),
|
||||||
|
'size_bytes' => (int) ($row['size_bytes'] ?? 0),
|
||||||
|
'created_by' => (string) ($row['created_by'] ?? ''),
|
||||||
|
'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
|
||||||
|
'download_url' => $kind === 'file'
|
||||||
|
? $basePath . '/api/ticket_attachment.php?id=' . $id
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class TicketCommentRepository {
|
||||||
|
public static function listForTicket(int $ticketId): array {
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, ticket_id, author_username, body, created_at_ms, updated_at_ms
|
||||||
|
FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at_ms ASC, id ASC'
|
||||||
|
);
|
||||||
|
$stmt->execute([$ticketId]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'ticket_id' => (int) $row['ticket_id'],
|
||||||
|
'author_username' => (string) $row['author_username'],
|
||||||
|
'body' => (string) $row['body'],
|
||||||
|
'created_at_ms' => (int) $row['created_at_ms'],
|
||||||
|
'updated_at_ms' => (int) $row['updated_at_ms'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add(int $ticketId, string $author, string $body): array {
|
||||||
|
$ticket = TicketRepository::getById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
throw new RuntimeException('not found');
|
||||||
|
}
|
||||||
|
$body = trim($body);
|
||||||
|
if ($body === '') {
|
||||||
|
throw new InvalidArgumentException('comment body required');
|
||||||
|
}
|
||||||
|
$author = mb_substr(trim($author), 0, 64);
|
||||||
|
if ($author === '') {
|
||||||
|
throw new InvalidArgumentException('author required');
|
||||||
|
}
|
||||||
|
$now = (int) round(microtime(true) * 1000);
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO ticket_comments (ticket_id, author_username, body, created_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$ticketId, $author, $body, $now, $now]);
|
||||||
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
$pdo->prepare('UPDATE tickets SET updated_at_ms = ? WHERE id = ?')->execute([$now, $ticketId]);
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'author_username' => $author,
|
||||||
|
'body' => $body,
|
||||||
|
'created_at_ms' => $now,
|
||||||
|
'updated_at_ms' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ final class TicketRepository {
|
|||||||
$total = (int) $countStmt->fetchColumn();
|
$total = (int) $countStmt->fetchColumn();
|
||||||
$sql = "SELECT id, ticket_id, title, brief, opened_at_ms, updated_at_ms, created_at_ms,
|
$sql = "SELECT id, ticket_id, title, brief, opened_at_ms, updated_at_ms, created_at_ms,
|
||||||
owner_username, verified_by_username, device_model, app_package, app_version,
|
owner_username, verified_by_username, device_model, app_package, app_version,
|
||||||
os_name, os_version, rating, tags_json
|
os_name, os_version, rating, tags_json, workflow_state, assignees_json
|
||||||
FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
|
FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
|
||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
@@ -181,6 +181,32 @@ final class TicketRepository {
|
|||||||
$sets[] = 'verified_by_username = ?';
|
$sets[] = 'verified_by_username = ?';
|
||||||
$params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
|
$params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
|
||||||
}
|
}
|
||||||
|
if (isset($fields['workflow_state'])) {
|
||||||
|
$state = TicketWorkflow::normalize((string) $fields['workflow_state']);
|
||||||
|
$sets[] = 'workflow_state = ?';
|
||||||
|
$params[] = $state;
|
||||||
|
$tags = TicketWorkflow::mergeTagsForState(
|
||||||
|
is_array($ticket['tags'] ?? null) ? $ticket['tags'] : [],
|
||||||
|
$state
|
||||||
|
);
|
||||||
|
$sets[] = 'tags_json = ?';
|
||||||
|
$params[] = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
if (array_key_exists('assignees', $fields)) {
|
||||||
|
$assignees = self::normalizeAssignees($fields['assignees']);
|
||||||
|
$err = UserRepository::validateUsernames(
|
||||||
|
array_map(static fn (array $a): string => $a['username'], $assignees)
|
||||||
|
);
|
||||||
|
if ($err !== null) {
|
||||||
|
throw new InvalidArgumentException($err);
|
||||||
|
}
|
||||||
|
$json = json_encode($assignees, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('json_encode failed');
|
||||||
|
}
|
||||||
|
$sets[] = 'assignees_json = ?';
|
||||||
|
$params[] = $json;
|
||||||
|
}
|
||||||
if ($sets === []) {
|
if ($sets === []) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,6 +262,8 @@ final class TicketRepository {
|
|||||||
'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')),
|
'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')),
|
||||||
'rating' => (int) ($row['rating'] ?? 0),
|
'rating' => (int) ($row['rating'] ?? 0),
|
||||||
'tags' => $tags,
|
'tags' => $tags,
|
||||||
|
'workflow_state' => TicketWorkflow::normalize((string) ($row['workflow_state'] ?? 'triage')),
|
||||||
|
'assignees' => self::parseAssigneesJson($row['assignees_json'] ?? '[]'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +275,61 @@ final class TicketRepository {
|
|||||||
$list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0);
|
$list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0);
|
||||||
$list['payload'] = is_array($payload) ? $payload : [];
|
$list['payload'] = is_array($payload) ? $payload : [];
|
||||||
$list['can_edit'] = Auth::canEditTicket($list);
|
$list['can_edit'] = Auth::canEditTicket($list);
|
||||||
|
try {
|
||||||
|
$list['comments'] = TicketCommentRepository::listForTicket((int) $row['id']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$list['comments'] = [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$list['attachments'] = TicketAttachmentRepository::listForTicket((int) $row['id']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$list['attachments'] = [];
|
||||||
|
}
|
||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<array{username:string,role:string}> */
|
||||||
|
public static function parseAssigneesJson(string $json): array {
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return self::normalizeAssignees($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $raw
|
||||||
|
* @return list<array{username:string,role:string}>
|
||||||
|
*/
|
||||||
|
public static function normalizeAssignees($raw): array {
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($raw as $item) {
|
||||||
|
if (is_string($item)) {
|
||||||
|
$item = ['username' => $item, 'role' => ''];
|
||||||
|
}
|
||||||
|
if (!is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$user = trim((string) ($item['username'] ?? ''));
|
||||||
|
if ($user === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = strtolower($user);
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$out[] = [
|
||||||
|
'username' => mb_substr($user, 0, 64),
|
||||||
|
'role' => mb_substr(trim((string) ($item['role'] ?? '')), 0, 32),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ticket_format_os_version(array $device): string {
|
function ticket_format_os_version(array $device): string {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ final class TicketTagCatalog {
|
|||||||
private const STATUS = [
|
private const STATUS = [
|
||||||
'open' => ['label' => 'open', 'bg' => '#22c55e'],
|
'open' => ['label' => 'open', 'bg' => '#22c55e'],
|
||||||
'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'],
|
'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'],
|
||||||
|
'blocked' => ['label' => 'blocked', 'bg' => '#b45309'],
|
||||||
|
'wont-fix' => ['label' => "won't fix", 'bg' => '#6b7280'],
|
||||||
'closed' => ['label' => 'closed', 'bg' => '#6b7280'],
|
'closed' => ['label' => 'closed', 'bg' => '#6b7280'],
|
||||||
'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'],
|
'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'],
|
||||||
'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'],
|
'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'],
|
||||||
|
|||||||
84
examples/crash_reporter/backend/src/TicketWorkflow.php
Normal file
84
examples/crash_reporter/backend/src/TicketWorkflow.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Canonical lifecycle (separate from free-form tags). */
|
||||||
|
final class TicketWorkflow {
|
||||||
|
public const TRIAGE = 'triage';
|
||||||
|
public const IN_PROGRESS = 'in_progress';
|
||||||
|
public const BLOCKED = 'blocked';
|
||||||
|
public const WONT_FIX = 'wont_fix';
|
||||||
|
public const DONE = 'done';
|
||||||
|
public const CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function all(): array {
|
||||||
|
return [
|
||||||
|
self::TRIAGE,
|
||||||
|
self::IN_PROGRESS,
|
||||||
|
self::BLOCKED,
|
||||||
|
self::WONT_FIX,
|
||||||
|
self::DONE,
|
||||||
|
self::CANCELLED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalize(string $state): string {
|
||||||
|
$s = strtolower(trim($state));
|
||||||
|
return in_array($s, self::all(), true) ? $s : self::TRIAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{id:string,label:string,bg:string} */
|
||||||
|
public static function labelDef(string $state): array {
|
||||||
|
$map = [
|
||||||
|
self::TRIAGE => ['id' => self::TRIAGE, 'label' => 'triage', 'bg' => '#64748b'],
|
||||||
|
self::IN_PROGRESS => ['id' => self::IN_PROGRESS, 'label' => 'in progress', 'bg' => '#3b82f6'],
|
||||||
|
self::BLOCKED => ['id' => self::BLOCKED, 'label' => 'blocked', 'bg' => '#b45309'],
|
||||||
|
self::WONT_FIX => ['id' => self::WONT_FIX, 'label' => "won't fix", 'bg' => '#6b7280'],
|
||||||
|
self::DONE => ['id' => self::DONE, 'label' => 'done', 'bg' => '#047857'],
|
||||||
|
self::CANCELLED => ['id' => self::CANCELLED, 'label' => 'cancelled', 'bg' => '#b91c1c'],
|
||||||
|
];
|
||||||
|
return $map[self::normalize($state)] ?? $map[self::TRIAGE];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map workflow to legacy status tag id for tag bar sync. */
|
||||||
|
public static function statusTagId(string $state): string {
|
||||||
|
return match (self::normalize($state)) {
|
||||||
|
self::IN_PROGRESS => 'in-progress',
|
||||||
|
self::BLOCKED => 'blocked',
|
||||||
|
self::WONT_FIX => 'wont-fix',
|
||||||
|
self::DONE => 'closed',
|
||||||
|
self::CANCELLED => 'rejected',
|
||||||
|
default => 'open',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace workflow/status tags; keep meta + custom tags.
|
||||||
|
*
|
||||||
|
* @param list<array{id:string,label:string,bg:string}> $tags
|
||||||
|
* @return list<array{id:string,label:string,bg:string}>
|
||||||
|
*/
|
||||||
|
public static function mergeTagsForState(array $tags, string $state): array {
|
||||||
|
$keep = [];
|
||||||
|
$statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all());
|
||||||
|
foreach (normalize_report_tags($tags) as $t) {
|
||||||
|
if ($t['id'] === TicketTagCatalog::META_ID) {
|
||||||
|
$keep[] = $t;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($t['id'], $statusIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$keep[] = $t;
|
||||||
|
}
|
||||||
|
$sid = self::statusTagId($state);
|
||||||
|
$preset = TicketTagCatalog::presets();
|
||||||
|
foreach ($preset as $p) {
|
||||||
|
if ($p['id'] === $sid) {
|
||||||
|
$keep[] = $p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TicketTagCatalog::normalize($keep);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
examples/crash_reporter/backend/src/UserRepository.php
Normal file
44
examples/crash_reporter/backend/src/UserRepository.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class UserRepository {
|
||||||
|
/** @return list<array{username:string,role:string}> */
|
||||||
|
public static function listForAssignees(): array {
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->query('SELECT username, role FROM users ORDER BY username ASC');
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'username' => (string) $row['username'],
|
||||||
|
'role' => (string) ($row['role'] ?? 'viewer'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $usernames */
|
||||||
|
public static function validateUsernames(array $usernames): ?string {
|
||||||
|
$want = [];
|
||||||
|
foreach ($usernames as $u) {
|
||||||
|
$u = trim((string) $u);
|
||||||
|
if ($u !== '') {
|
||||||
|
$want[strtolower($u)] = $u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($want === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->query('SELECT username FROM users');
|
||||||
|
$have = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$have[strtolower((string) $row['username'])] = true;
|
||||||
|
}
|
||||||
|
foreach ($want as $lower => $orig) {
|
||||||
|
if (!isset($have[$lower])) {
|
||||||
|
return 'unknown user: ' . $orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$rating = (int) ($ticket['rating'] ?? 0);
|
$rating = (int) ($ticket['rating'] ?? 0);
|
||||||
|
$workflowState = TicketWorkflow::normalize((string) ($ticket['workflow_state'] ?? 'triage'));
|
||||||
|
$assignees = is_array($ticket['assignees'] ?? null) ? $ticket['assignees'] : [];
|
||||||
|
$comments = is_array($ticket['comments'] ?? null) ? $ticket['comments'] : [];
|
||||||
|
$attachments = is_array($ticket['attachments'] ?? null) ? $ticket['attachments'] : [];
|
||||||
|
$canAttach = (bool) Auth::user();
|
||||||
$starsHtml = '';
|
$starsHtml = '';
|
||||||
for ($i = 1; $i <= 5; $i++) {
|
for ($i = 1; $i <= 5; $i++) {
|
||||||
$starsHtml .= '<span class="star' . ($i <= $rating ? ' star--on' : '') . '" aria-hidden="true"></span>';
|
$starsHtml .= '<span class="star' . ($i <= $rating ? ' star--on' : '') . '" aria-hidden="true"></span>';
|
||||||
@@ -106,6 +111,26 @@ for ($i = 1; $i <= 5; $i++) {
|
|||||||
<input type="text" name="verified_by_username" maxlength="64"
|
<input type="text" name="verified_by_username" maxlength="64"
|
||||||
value="<?= h($ticket['verified_by_username'] ?? '') ?>">
|
value="<?= h($ticket['verified_by_username'] ?? '') ?>">
|
||||||
</label>
|
</label>
|
||||||
|
<label class="ticket-field">
|
||||||
|
<span data-i18n="ticket.lifecycle">Lifecycle</span>
|
||||||
|
<select name="workflow_state" id="ticket-workflow-state">
|
||||||
|
<?php foreach (TicketWorkflow::all() as $ws): ?>
|
||||||
|
<option value="<?= h($ws) ?>"<?= $workflowState === $ws ? ' selected' : '' ?>>
|
||||||
|
<?= h(TicketWorkflow::labelDef($ws)['label']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="ticket-field ticket-assignees-field">
|
||||||
|
<span data-i18n="ticket.assignees">Assignees</span>
|
||||||
|
<div class="assignee-editor" id="assignee-editor" aria-live="polite"></div>
|
||||||
|
<div class="assignee-add-row">
|
||||||
|
<select id="assignee-user-pick" aria-label="Add assignee">
|
||||||
|
<option value="">—</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn" id="assignee-add-btn" data-i18n="ticket.assignee_add">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="ticket-field">
|
<label class="ticket-field">
|
||||||
<span data-i18n="col.rating">Rating</span>
|
<span data-i18n="col.rating">Rating</span>
|
||||||
<select name="rating">
|
<select name="rating">
|
||||||
@@ -123,6 +148,9 @@ for ($i = 1; $i <= 5; $i++) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<script type="application/json" id="ticket-assignees-data"><?=
|
||||||
|
safe_json_encode($assignees, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||||
|
?></script>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<section class="stack-block ticket-readonly-issue">
|
<section class="stack-block ticket-readonly-issue">
|
||||||
<h2><?= h($ticket['title'] ?? '') ?></h2>
|
<h2><?= h($ticket['title'] ?? '') ?></h2>
|
||||||
@@ -164,6 +192,18 @@ for ($i = 1; $i <= 5; $i++) {
|
|||||||
</div>
|
</div>
|
||||||
<?php if (!$canEdit): ?>
|
<?php if (!$canEdit): ?>
|
||||||
<div class="card card--lift"><h3 data-i18n="ticket.workflow">Workflow</h3>
|
<div class="card card--lift"><h3 data-i18n="ticket.workflow">Workflow</h3>
|
||||||
|
<p><span data-i18n="ticket.lifecycle">Lifecycle</span>:
|
||||||
|
<?= h(TicketWorkflow::labelDef($workflowState)['label']) ?></p>
|
||||||
|
<p><span data-i18n="ticket.assignees">Assignees</span>:
|
||||||
|
<?php if ($assignees === []): ?>
|
||||||
|
<span class="muted">—</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($assignees as $a): ?>
|
||||||
|
<span class="assignee-pill"><?= h($a['username']) ?><?php if (!empty($a['role'])): ?>
|
||||||
|
<span class="muted">(<?= h($a['role']) ?>)</span><?php endif; ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
<p><span data-i18n="ticket.owner">Owner</span>: <?= h($ticket['owner_username'] ?? '—') ?></p>
|
<p><span data-i18n="ticket.owner">Owner</span>: <?= h($ticket['owner_username'] ?? '—') ?></p>
|
||||||
<p><span data-i18n="ticket.verified_by">Verified by</span>:
|
<p><span data-i18n="ticket.verified_by">Verified by</span>:
|
||||||
<?= h($ticket['verified_by_username'] ?? '—') ?></p>
|
<?= h($ticket['verified_by_username'] ?? '—') ?></p>
|
||||||
@@ -173,6 +213,89 @@ for ($i = 1; $i <= 5; $i++) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="ticket-attachments" id="ticket-attachments" data-ticket-id="<?= $ticketDbId ?>">
|
||||||
|
<h2 data-i18n="ticket.attachments">Attachments</h2>
|
||||||
|
<ul class="ticket-attachment-list" id="ticket-attachment-list">
|
||||||
|
<?php foreach ($attachments as $att): ?>
|
||||||
|
<li class="ticket-attachment ticket-attachment--<?= h($att['kind']) ?>"
|
||||||
|
data-attachment-id="<?= (int) ($att['id'] ?? 0) ?>">
|
||||||
|
<span class="attachment-provider"><?= h($att['provider_label'] ?? 'File') ?></span>
|
||||||
|
<?php if (($att['kind'] ?? '') === 'link' && !empty($att['external_url'])): ?>
|
||||||
|
<a href="<?= h($att['external_url']) ?>" target="_blank" rel="noopener noreferrer"><?= h($att['title'] ?: $att['external_url']) ?></a>
|
||||||
|
<?php elseif (!empty($att['download_url'])): ?>
|
||||||
|
<a href="<?= h($att['download_url']) ?>"><?= h($att['title'] ?: $att['original_name']) ?></a>
|
||||||
|
<?php if (!empty($att['size_bytes'])): ?>
|
||||||
|
<span class="muted">(<?= h(number_format((int) $att['size_bytes'] / 1024, 1)) ?> KB)</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span><?= h($att['title'] ?? '') ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="muted attachment-meta"><?= h($att['created_by'] ?? '') ?></span>
|
||||||
|
<?php if ($canAttach): ?>
|
||||||
|
<button type="button" class="btn btn-small attachment-remove" data-id="<?= (int) ($att['id'] ?? 0) ?>">×</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php if ($canAttach): ?>
|
||||||
|
<div class="ticket-attachment-add cards cards--lift">
|
||||||
|
<form class="ticket-link-form" id="ticket-link-form">
|
||||||
|
<h3 data-i18n="ticket.attach_link">Add link</h3>
|
||||||
|
<p class="muted" data-i18n="ticket.attach_link_hint">Google Docs, Sheets, Drive, Meet, or any HTTPS URL.</p>
|
||||||
|
<label class="ticket-field">
|
||||||
|
<span data-i18n="ticket.link_title">Title (optional)</span>
|
||||||
|
<input type="text" name="title" maxlength="256">
|
||||||
|
</label>
|
||||||
|
<label class="ticket-field">
|
||||||
|
<span>URL</span>
|
||||||
|
<input type="url" name="url" required placeholder="https://docs.google.com/...">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" data-i18n="ticket.attach_link_btn">Attach link</button>
|
||||||
|
<span class="muted" id="ticket-link-status"></span>
|
||||||
|
</form>
|
||||||
|
<form class="ticket-file-form" id="ticket-file-form" enctype="multipart/form-data">
|
||||||
|
<h3 data-i18n="ticket.attach_file">Upload file</h3>
|
||||||
|
<p class="muted" data-i18n="ticket.attach_file_hint">Max 16 MiB — images, logs, pdf, zip, …</p>
|
||||||
|
<label class="ticket-field">
|
||||||
|
<span data-i18n="ticket.link_title">Title (optional)</span>
|
||||||
|
<input type="text" name="title" maxlength="256">
|
||||||
|
</label>
|
||||||
|
<label class="ticket-field">
|
||||||
|
<span data-i18n="ticket.file_pick">File</span>
|
||||||
|
<input type="file" name="file" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary" data-i18n="ticket.attach_file_btn">Upload</button>
|
||||||
|
<span class="muted" id="ticket-file-status"></span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ticket-comments" id="ticket-comments" data-ticket-id="<?= $ticketDbId ?>">
|
||||||
|
<h2 data-i18n="ticket.comments">Comments</h2>
|
||||||
|
<ul class="ticket-comment-list" id="ticket-comment-list">
|
||||||
|
<?php foreach ($comments as $c): ?>
|
||||||
|
<li class="ticket-comment">
|
||||||
|
<div class="ticket-comment-meta">
|
||||||
|
<strong><?= h($c['author_username']) ?></strong>
|
||||||
|
<span class="muted"><?= h(date('c', (int) (($c['created_at_ms'] ?? 0) / 1000))) ?></span>
|
||||||
|
</div>
|
||||||
|
<pre class="ticket-comment-body"><?= h($c['body']) ?></pre>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php if ($canEdit || Auth::user()): ?>
|
||||||
|
<form class="ticket-comment-form" id="ticket-comment-form">
|
||||||
|
<label class="ticket-field">
|
||||||
|
<span data-i18n="ticket.comment_add">Add comment</span>
|
||||||
|
<textarea name="body" rows="4" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary" data-i18n="ticket.comment_post">Post</button>
|
||||||
|
<span class="muted" id="ticket-comment-status"></span>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="detail-tree" id="detail-tree">
|
<div class="detail-tree" id="detail-tree">
|
||||||
<?php if (!empty($p['source_file']) || !empty($p['alpha_block'])): ?>
|
<?php if (!empty($p['source_file']) || !empty($p['alpha_block'])): ?>
|
||||||
<div class="detail-tree-item">
|
<div class="detail-tree-item">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
|
RECONNECT_KNOWN_DEVICES=${RECONNECT_KNOWN_DEVICES:-no}
|
||||||
|
|
||||||
# Hints for wireless debugging. Edit as needed.
|
# Hints for wireless debugging. Edit as needed.
|
||||||
## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
|
## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
|
||||||
declare -A CONNECTIONS_LIST_HINTS
|
declare -A CONNECTIONS_LIST_HINTS
|
||||||
@@ -173,7 +175,9 @@ if [[ ! -f "$APK" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
reconnect_known_devices
|
if [[ "${RECONNECT_KNOWN_DEVICES}" =~ "[yY]*" ]]; then
|
||||||
|
reconnect_known_devices
|
||||||
|
fi
|
||||||
|
|
||||||
mapfile -t DEVICES < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }')
|
mapfile -t DEVICES < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user