diff --git a/.github/workflows/bug-tracker.yml b/.github/workflows/bug-tracker.yml new file mode 100644 index 00000000..cec7ee13 --- /dev/null +++ b/.github/workflows/bug-tracker.yml @@ -0,0 +1,33 @@ +name: Bug tracker validation +on: + pull_request: + push: + branches: [feature/unified-bug-tracker] +permissions: + contents: read +jobs: + website: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: pnpm/action-setup@v4 + with: + version: 9.0.0 + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - uses: actions/cache@v4 + with: + path: .image-cache + key: image-optimization-cache-${{ runner.os }}-${{ github.run_id }} + restore-keys: image-optimization-cache-${{ runner.os }}- + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:bugs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: bug-tracker-browser-results + path: test-results diff --git a/.gitignore b/.gitignore index 020ccc06..671ce705 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ social-card-template.html # Windows artifacts nul Thumbs.db +test-results/ +playwright-report/ *.tmp # Version control temp files diff --git a/BUG_TRACKER.md b/BUG_TRACKER.md new file mode 100644 index 00000000..08c994e2 --- /dev/null +++ b/BUG_TRACKER.md @@ -0,0 +1,27 @@ +# Unified OpenHD bug tracker + +The website uses `https://openhd.tech/api/bugs` by default. Set `BUG_REPORT_API_URL` at build time to change it. No credentials belong in the website. This extends PR #12; FleetControl owns the backend, so the unused Python requirements are removed. + +`/bugs` supports search, type, status, repository and component filters with server pagination. GitHub search caps results at 1,000; narrow filters for older results. GitHub data is cached for one minute, with rate-limit backoff. `/bugs/detail?repo=OpenHD/OpenHD&number=123` shows descriptions as escaped text, metadata, related pull requests and the original link. + +`/bugs/report` requires title, description, reproduction steps, version, component, Air hardware, Ground hardware, and both archives. ZIP, tar.gz and tgz are accepted; 50 MiB per archive is the default. The separately marked incomplete-diagnostics workflow requires an explanation of at least 20 characters, holds reports privately for administrator approval, and accepts any available archive. + +Turnstile is verified server-side. Uploads show progress, and the browser keeps the submission token in sessionStorage during pending or interrupted requests. Accepted reports return HTTP 202; the client then polls for the GitHub number and URL. Email failures do not affect issue creation. + +Private archives are available only after a FleetControl administrator signs in at `https://openhd.tech/#/admin/bugs`. The website sends no cross-origin session cookies and receives no permanent download links. Logs are never sent to GitHub or attached to email. + +## Deployment + +FleetControl's `docs/bug-tracker.md` contains the API contract, configuration and recovery instructions. Configure the API first, then validate this branch with Node 24 and pnpm 9: + +``` +pnpm install --frozen-lockfile +pnpm typecheck +pnpm build +pnpm exec playwright install chromium +pnpm test:bugs +``` + +Playwright uses a built website and mocked integration responses; it does not prove live GitHub creation, human verification or email delivery. Production activation requires the live smoke test in the backend documentation. + +Website pushes to `main` publish under `/staging/`; pushes to `online` publish the public root. Feature-branch and PR validation do not deploy. Set the Turnstile widget hostname to `openhdfpv.org` and allow that exact HTTPS origin on FleetControl. The submit button stays disabled until the API reports that all required backend integrations are configured. diff --git a/docusaurus.config.ts b/docusaurus.config.ts index f0596ee8..19f4e64a 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -24,6 +24,8 @@ const config: Config = { baseUrl: isStaging ? '/staging/' : '/', noIndex: isStaging, + customFields: {bugReportApiUrl: process.env.BUG_REPORT_API_URL || 'https://openhd.tech/api/bugs'}, + // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. organizationName: 'OpenHD', // Usually your GitHub org/user name. @@ -366,6 +368,11 @@ const config: Config = { label: 'Changelog', position: 'left', }, + { + to: '/bugs', + label: 'Bug Tracker', + position: 'left', + }, { type: 'docsVersionDropdown', position: 'right', diff --git a/package.json b/package.json index c95cc212..26b00349 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc" + "typecheck": "tsc", + "test:bugs": "playwright test" }, "dependencies": { "@acid-info/docusaurus-og": "1.0.4-alpha.0", @@ -40,6 +41,7 @@ "@docusaurus/module-type-aliases": "3.10.2", "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", + "@playwright/test": "1.58.2", "glob": "^13.0.6", "sharp": "^0.35.3", "typescript": "~5.7.3" diff --git a/patches/@acid-info__docusaurus-og@1.0.4-alpha.0.patch b/patches/@acid-info__docusaurus-og@1.0.4-alpha.0.patch index 57f5d2df..341eba72 100644 --- a/patches/@acid-info__docusaurus-og@1.0.4-alpha.0.patch +++ b/patches/@acid-info__docusaurus-og@1.0.4-alpha.0.patch @@ -57,3 +57,15 @@ index 106fe9ff80bf9d122a4f43c7e8fa0a849d01a215..567138766af782e53e3c0d4e4a74b5e8 + }; } exports.PagesPlugin = PagesPlugin; +diff --git a/lib/server/document.js b/lib/server/document.js +--- a/lib/server/document.js ++++ b/lib/server/document.js +@@ -40,7 +40,7 @@ class Document { + } + load = async () => { + const htmlString = await fsp.readFile(this.path, 'utf-8'); +- this.root = (0, node_html_parser_1.parse)(htmlString); ++ this.root = (0, node_html_parser_1.parse)(htmlString, { parseNoneClosedTags: true }); + this.loaded = true; + }; + write = async () => { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..9e26a119 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from '@playwright/test'; +export default defineConfig({ + testDir: './tests', testMatch: '**/bugs.spec.ts', timeout: 30000, + use: {baseURL: 'http://127.0.0.1:3017', trace: 'retain-on-failure'}, + projects: [ + {name: 'desktop', use: {viewport: {width: 1440, height: 1000}, colorScheme: 'light'}}, + {name: 'mobile-dark', use: {viewport: {width: 390, height: 844}, colorScheme: 'dark'}}, + ], + webServer: {command: 'pnpm exec docusaurus serve --host 127.0.0.1 --port 3017 --no-open', url: 'http://127.0.0.1:3017/bugs', reuseExistingServer: false, timeout: 120000}, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b129698..f779d600 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@acid-info/docusaurus-og@1.0.4-alpha.0': - hash: vtyinecjv573vohpamzlmkubgi + hash: gbdez57tprgcrczxk6m7tfzoye path: patches/@acid-info__docusaurus-og@1.0.4-alpha.0.patch importers: @@ -15,7 +15,7 @@ importers: dependencies: '@acid-info/docusaurus-og': specifier: 1.0.4-alpha.0 - version: 1.0.4-alpha.0(patch_hash=vtyinecjv573vohpamzlmkubgi)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.7.3) + version: 1.0.4-alpha.0(patch_hash=gbdez57tprgcrczxk6m7tfzoye)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.7.3) '@docusaurus/core': specifier: 3.10.2 version: 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.16.0)(debug@4.4.3)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.7.3) @@ -80,6 +80,9 @@ importers: '@docusaurus/types': specifier: 3.10.2 version: 3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@playwright/test': + specifier: 1.58.2 + version: 1.58.2 glob: specifier: ^13.0.6 version: 13.0.6 @@ -1878,6 +1881,11 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -3859,6 +3867,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5193,6 +5206,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6714,7 +6737,7 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - '@acid-info/docusaurus-og@1.0.4-alpha.0(patch_hash=vtyinecjv573vohpamzlmkubgi)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.7.3)': + '@acid-info/docusaurus-og@1.0.4-alpha.0(patch_hash=gbdez57tprgcrczxk6m7tfzoye)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.7.3)': dependencies: '@docusaurus/core': 3.8.1(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.7.3) '@docusaurus/module-type-aliases': 3.8.1(@swc/core@1.16.0)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -10255,6 +10278,10 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -12352,6 +12379,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -14018,6 +14048,14 @@ snapshots: pvutils: 1.2.0 tslib: 2.8.1 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/src/components/BugApi.ts b/src/components/BugApi.ts new file mode 100644 index 00000000..0e68fe03 --- /dev/null +++ b/src/components/BugApi.ts @@ -0,0 +1,15 @@ +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +export type BugEntry = { id: number; number: number; title: string; body?: string; state: string; html_url: string; updated_at: string; repository_url: string; pull_request?: {merged_at?: string}; merged?: boolean; labels: {name: string}[]; related?: BugEntry[] }; +export function useBugApi() { + const {siteConfig} = useDocusaurusContext(); + const endpoint = String(siteConfig.customFields?.bugReportApiUrl || '').replace(/\/$/, ''); + return endpoint.startsWith('https://') ? endpoint : ''; +} +export function entryType(entry: BugEntry) { return entry.pull_request ? 'Pull Request' : entry.labels.some(label => label.name === 'website-report') ? 'Bug' : 'GitHub Issue'; } +export function labelValue(entry: BugEntry, prefix: string) { return entry.labels.find(label => label.name.startsWith(`${prefix}:`))?.name.slice(prefix.length + 1) || '—'; } +export async function bugFetch(url: string, options?: RequestInit) { + const response = await fetch(url, options); + const result = await response.json(); + if (!response.ok) {const error = new Error((result.message || 'The tracker is temporarily unavailable.') + (response.headers.get('Retry-After') ? ` Retry in ${response.headers.get('Retry-After')} seconds.` : '')); Object.assign(error, {status: response.status}); throw error;} + return result; +} diff --git a/src/pages/bugs.module.css b/src/pages/bugs.module.css new file mode 100644 index 00000000..09304255 --- /dev/null +++ b/src/pages/bugs.module.css @@ -0,0 +1,3 @@ +.page{max-width:1200px;margin:auto;padding:3rem 1.5rem 5rem;min-height:65vh}.header{display:flex;align-items:center;justify-content:space-between;gap:2rem;margin-bottom:2rem}.header h1{margin-bottom:.4rem}.header p,.note{color:var(--ifm-color-emphasis-600)}.controls{display:flex;gap:1rem;margin-bottom:1.5rem}.controls input{flex:1}.controls select,.controls input{padding:.75rem;border:1px solid var(--ifm-color-emphasis-300);border-radius:7px;background:var(--ifm-background-surface-color);color:var(--ifm-font-color-base)}.list{border:1px solid var(--ifm-color-emphasis-200);border-radius:9px;overflow:hidden}.item{display:grid;grid-template-columns:minmax(0,3fr) 1fr .6fr 1fr;gap:1rem;padding:1.1rem 1.2rem;border-bottom:1px solid var(--ifm-color-emphasis-200);align-items:center;color:var(--ifm-font-color-base)}.item:last-child{border-bottom:0}.item:hover{background:var(--ifm-hover-overlay);text-decoration:none}.title{font-weight:600;color:var(--ifm-color-primary)}.note{font-size:.85rem;margin-top:1.5rem}.form{max-width:780px}.form label{display:block;font-weight:600;margin:1.2rem 0}.form input,.form textarea,.form select{display:block;width:100%;margin-top:.45rem;padding:.75rem;background:var(--ifm-background-surface-color);color:var(--ifm-font-color-base);border:1px solid var(--ifm-color-emphasis-300);border-radius:7px}.columns{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.notice{padding:1rem;border:1px solid var(--ifm-color-emphasis-300);border-radius:8px;margin:1rem 0}.error{color:var(--ifm-color-danger);font-weight:600}.help{font-size:.9rem;color:var(--ifm-color-emphasis-700)}@media(max-width:760px){.header,.controls{flex-direction:column;align-items:stretch}.item{grid-template-columns:1fr 1fr}.title{grid-column:1/-1}.columns{grid-template-columns:1fr}} +.controls{flex-wrap:wrap}.controls input{min-width:200px}.tableWrap{overflow-x:auto}.table{display:table;width:100%;font-size:.92rem}.table td{vertical-align:top}.table td:first-child{min-width:220px}.meta{display:block;color:var(--ifm-color-emphasis-600)}.badge{border:1px solid var(--ifm-color-emphasis-300);border-radius:20px;padding:.1rem .5rem;white-space:nowrap}.pagination{display:flex;gap:1rem;align-items:center;margin-top:1.5rem}.pagination button{padding:.5rem 1rem}.description{white-space:pre-wrap;overflow-wrap:anywhere;padding:1.5rem;background:var(--ifm-background-surface-color);border:1px solid var(--ifm-color-emphasis-300);border-radius:8px}.form fieldset{border:0;padding:0;margin:0}.form .check{display:flex;align-items:center;gap:.7rem;font-weight:400}.form .check input{width:auto;margin:0}.trap{position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden}.form progress{display:block;width:100%;margin:1rem 0}button{cursor:pointer}button:disabled{cursor:default} +.page{width:100%;min-width:0;box-sizing:border-box}.tableWrap{width:100%;max-width:100%;min-width:0}.table{min-width:700px}.controls>*{max-width:100%} diff --git a/src/pages/bugs.tsx b/src/pages/bugs.tsx new file mode 100644 index 00000000..a902dc28 --- /dev/null +++ b/src/pages/bugs.tsx @@ -0,0 +1,40 @@ +import React, {useEffect, useState} from 'react'; +import Layout from '@theme/Layout'; +import Link from '@docusaurus/Link'; +import {BugEntry, bugFetch, entryType, labelValue, useBugApi} from '../components/BugApi'; +import styles from './bugs.module.css'; + +export default function Bugs() { + const endpoint = useBugApi(); + const [items, setItems] = useState([]), [total, setTotal] = useState(0), [partial, setPartial] = useState(false); + const [error, setError] = useState(''), [loading, setLoading] = useState(false); + const [query, setQuery] = useState(''), [search, setSearch] = useState(''), [type, setType] = useState('all'), [status, setStatus] = useState('all'), [repo, setRepo] = useState(''), [repository, setRepository] = useState(''), [component, setComponent] = useState(''), [components, setComponents] = useState([]), [page, setPage] = useState(1); + useEffect(() => { if (query === search && repo === repository) return; const timer = setTimeout(() => { setSearch(query); setRepository(repo); setPage(1); }, 450); return () => clearTimeout(timer); }, [query, repo, search, repository]); + useEffect(() => { if (endpoint) bugFetch(`${endpoint}/config`).then(result => setComponents(result.components)).catch(() => {}); }, [endpoint]); + useEffect(() => { + if (!endpoint) return; + const abort = new AbortController(); setLoading(true); setError(''); + const params = new URLSearchParams({q: search, type, status, repository, component, page: String(page)}); + bugFetch(`${endpoint}?${params}`, {signal: abort.signal}).then(result => { setItems(result.items || []); setTotal(result.total_count); setPartial(Boolean(result.incomplete_results)); }).catch(e => { if (!abort.signal.aborted) { setError(e.message); setItems([]); } }).finally(() => { if (!abort.signal.aborted) setLoading(false); }); + return () => abort.abort(); + }, [endpoint, search, type, status, repository, component, page]); + const pages = Math.min(34, Math.ceil(Math.min(total, 1000) / 30)); + return
+

OpenHD Bug Tracker

Community bug reports, GitHub issues and pull requests in one place.

Report a bug
+
+ setQuery(e.target.value)}/> + + + setRepo(e.target.value)}/> + +
+ {!endpoint &&

The tracker API is being configured.

} + {loading &&

Loading GitHub activity…

}{error &&

{error}

} + {!loading && !error && endpoint && !items.length &&

No entries match these filters.

} + {!error &&
{items.map(item => + + )}
Number / TitleTypeRepository / ComponentStatusPriorityUpdated
#{item.number} {item.title}{entryType(item)}{item.repository_url.split('/repos/')[1]}{labelValue(item, 'component')}{item.pull_request?.merged_at ? 'merged' : item.state}{labelValue(item, 'priority')}{new Date(item.updated_at).toLocaleDateString()}
} + {pages > 0 && } +

Live GitHub data, cached for one minute. Unlabelled priorities and components appear as “—”.{total > 1000 && ' GitHub search shows the first 1,000 results; narrow your filters for older activity.'}{partial && ' GitHub returned partial results; try a narrower search.'}

+
; +} diff --git a/src/pages/bugs/detail.tsx b/src/pages/bugs/detail.tsx new file mode 100644 index 00000000..5be9e91c --- /dev/null +++ b/src/pages/bugs/detail.tsx @@ -0,0 +1,26 @@ +import React, {useEffect, useState} from 'react'; +import Layout from '@theme/Layout'; +import Link from '@docusaurus/Link'; +import {BugEntry, bugFetch, entryType, labelValue, useBugApi} from '../../components/BugApi'; +import styles from '../bugs.module.css'; + +export default function BugDetail() { + const endpoint = useBugApi(); const [entry, setEntry] = useState(null), [error, setError] = useState(''); + useEffect(() => { + if (!endpoint) {setError('The tracker API is being configured.'); return;} + const params = new URLSearchParams(window.location.search), repo = params.get('repo') || '', number = params.get('number') || ''; + if (!/^OpenHD\/[\w.-]+$/.test(repo) || !/^[1-9]\d*$/.test(number)) {setError('Invalid entry link.'); return;} + const abort = new AbortController(); + bugFetch(`${endpoint}/entry/${repo}/${number}`, {signal: abort.signal}).then(setEntry).catch(e => {if (!abort.signal.aborted) setError(e.message);}); + return () => abort.abort(); + }, [endpoint]); + return
← Back to tracker + {error ?

{error}

: !entry ?

Loading details…

: <> +

#{entry.number} {entry.title}

{entryType(entry)} · {entry.merged ? 'merged' : entry.state} · Component: {labelValue(entry, 'component')} · Priority: {labelValue(entry, 'priority')}

+

Updated {new Date(entry.updated_at).toLocaleString()} · View original on GitHub

+
{entry.body || 'No description provided.'}
+ {entry.related?.length > 0 &&

Associated pull requests

{entry.related.map(pr =>

#{pr.number} {pr.title} · {pr.pull_request?.merged_at ? 'merged' : pr.state}

)}
} + {entry.labels.some(label => label.name === 'website-report') &&

Diagnostic files

Diagnostics are private and may expire under the retention policy. Authorized maintainers can sign in to FleetControl to review and download Air and Ground logs.

} + } +
; +} diff --git a/src/pages/bugs/report.tsx b/src/pages/bugs/report.tsx new file mode 100644 index 00000000..aa4d2dac --- /dev/null +++ b/src/pages/bugs/report.tsx @@ -0,0 +1,79 @@ +import React, {useEffect, useRef, useState} from 'react'; +import Layout from '@theme/Layout'; +import Link from '@docusaurus/Link'; +import {bugFetch, useBugApi} from '../../components/BugApi'; +import styles from '../bugs.module.css'; + +type Config = {enabled: boolean; maxArchiveBytes: number; components: string[]; turnstileSiteKey: string}; +type Submission = {id: string; number?: number; url?: string; status: string; message: string}; +declare global {interface Window {turnstile?: {render(element: HTMLElement, options: Record): string; remove(id: string): void; reset(id: string): void}}} +const fields = [['title', 'Bug title', 160], ['description', 'Description', 10000], ['steps', 'Steps to reproduce', 10000], ['version', 'OpenHD version', 200], ['airHardware', 'Air hardware', 200], ['groundHardware', 'Ground hardware', 200]] as const; +export default function ReportBug() { + const endpoint = useBugApi(), widget = useRef(null), widgetId = useRef(''); + const [config, setConfig] = useState(null), [token, setToken] = useState(''), [human, setHuman] = useState(''); + const [values, setValues] = useState>({}), [air, setAir] = useState(null), [ground, setGround] = useState(null); + const [incomplete, setIncomplete] = useState(false), [busy, setBusy] = useState(false), [progress, setProgress] = useState(0), [error, setError] = useState(''), [submission, setSubmission] = useState(null), [uncertain, setUncertain] = useState(false); + useEffect(() => { + if (!endpoint) return; + Promise.all([bugFetch(`${endpoint}/config`), bugFetch(`${endpoint}/challenge`)]).then(([c, challenge]) => { + setConfig(c); const saved = sessionStorage.getItem('openhd-bug-submission'); setToken(saved || challenge.token); + if (saved) {setUncertain(true); bugFetch(`${endpoint}/submission`, {headers: {'X-Bug-Token': saved}}).then(setSubmission).catch(() => setError('A previous submission may be pending. Check its status before submitting again.'));} + }).catch(e => setError(e.message)); + }, [endpoint]); + useEffect(() => { + if (!config?.enabled || !widget.current) return; + let disposed = false; + const render = () => {if (!disposed && window.turnstile && widget.current) widgetId.current = window.turnstile.render(widget.current, {sitekey: config.turnstileSiteKey, action: 'bug-report', callback: setHuman, 'expired-callback': () => setHuman(''), 'error-callback': () => {setHuman(''); setError('Human verification could not load. Please retry.');}});}; + const existing = document.querySelector('script[data-openhd-turnstile]'); + const script = existing || document.createElement('script'); + if (window.turnstile) render(); else {script.addEventListener('load', render); if (!existing) {script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; script.async = true; script.dataset.openhdTurnstile = 'true'; document.head.appendChild(script);}} + return () => {disposed = true; script.removeEventListener('load', render); if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);}; + }, [config]); + useEffect(() => { + if (!submission || submission.status !== 'processing') return; + const timer = setInterval(() => {bugFetch(`${endpoint}/submission`, {headers: {'X-Bug-Token': token}}).then(setSubmission).catch(e => setError(e.message));}, 5000); + return () => clearInterval(timer); + }, [submission?.status, endpoint, token]); + const valid = (file: File|null) => Boolean(file && file.size > 0 && file.size <= (config?.maxArchiveBytes || 52428800) && /\.(zip|tgz|tar\.gz)$/i.test(file.name)); + const archivesReady = incomplete ? (!air || valid(air)) && (!ground || valid(ground)) && !(air && ground) && (values.diagnosticReason || '').trim().length >= 20 : valid(air) && valid(ground); + const ready = Boolean(endpoint && config?.enabled && token && human && fields.every(([key]) => values[key]?.trim()) && values.component && archivesReady && !busy && !submission && !uncertain); + async function checkStatus() {setError(''); try {setSubmission(await bugFetch(`${endpoint}/submission`, {headers: {'X-Bug-Token': token}}));} catch(e) {if (e.status === 404) {setUncertain(false); setError('No saved report was found. You can retry this form using the same submission token.');} else setError(e.message);}} + async function submit(event: React.FormEvent) { + event.preventDefault(); if (!ready) return; + const body = new FormData(); for (const [key, value] of Object.entries(values)) body.set(key, value); + body.set('workflow', incomplete ? 'incomplete' : 'normal'); body.set('cf-turnstile-response', human); + if (air) body.set('air_logs', air); if (ground) body.set('ground_logs', ground); + setBusy(true); setError(''); setProgress(0); sessionStorage.setItem('openhd-bug-submission', token); + try { + const result = await new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); request.open('POST', endpoint); request.setRequestHeader('X-Bug-Token', token); request.timeout = 300000; + request.upload.onprogress = e => {if (e.lengthComputable) setProgress(Math.round(100 * e.loaded / e.total));}; + request.onload = () => {try {const response = JSON.parse(request.responseText); if (request.status >= 200 && request.status < 300) resolve(response); else {sessionStorage.removeItem('openhd-bug-submission'); reject(Error(response.message || 'Submission failed.'));}} catch {setUncertain(true); reject(Error('Server response was interrupted. Check submission status before retrying.'));}}; + request.onerror = request.ontimeout = () => {setUncertain(true); reject(Error('Connection interrupted. Check submission status before retrying.'));}; + request.send(body); + }); + setSubmission(result); + } catch(e) {setError(e.message); setHuman(''); if (widgetId.current) window.turnstile?.reset(widgetId.current);} + finally {setBusy(false);} + } + async function newReport() {sessionStorage.removeItem('openhd-bug-submission'); setSubmission(null); setUncertain(false); setError(''); setToken((await bugFetch(`${endpoint}/challenge`)).token); setHuman(''); if (widgetId.current) window.turnstile?.reset(widgetId.current);} + return

Report a bug

Help us reproduce the problem. Air and Ground diagnostics are required for normal reports.

Back to tracker
+ {!endpoint || config && !config.enabled ?

Submissions are awaiting server configuration. Please try again later.

: !config ?

Loading report form…

: null} + {error &&

{error}

} + {submission ?

{submission.status === 'created' ? `Bug #${submission.number} created` : 'Report received'}

{submission.message}

{submission.url && /^https:\/\/github\.com\/OpenHD\//.test(submission.url) &&

View your issue on GitHub

}

Your archives remain private. They are never attached to GitHub issues or notification emails.

{submission.status !== 'processing' && }
:
+
Bug details + {fields.map(([key, label, max]) =>