Skip to content

Commit dc6f416

Browse files
committed
Add multi-step connection test with actionable error messages
Replace single-pass Test Connection with per-step diagnostics: - API reachable → Auth → Campaign → System match - Each step shows check/xmark/warning with specific guidance - CORS errors: show origin URL and whitelist instructions - 401: direct to Integrations page for new API key - 403: check Read/Write/Sync permissions - 404: check campaign ID - 429: rate limit guidance - System match: show available foundry_system_ids on mismatch https://claude.ai/code/session_01J3F55nRmeFHTtK4BnYgGGk
1 parent 58ff9a4 commit dc6f416

3 files changed

Lines changed: 112 additions & 44 deletions

File tree

scripts/sync-dashboard.mjs

Lines changed: 104 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,9 +1579,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15791579
// ---------------------------------------------------------------------------
15801580

15811581
/**
1582-
* Test connection to Chronicle API using current config tab values.
1583-
* Reads URL, key, and campaign ID from the Config tab inputs, attempts
1584-
* a lightweight API call, and shows the result inline.
1582+
* Test connection to Chronicle with multi-step diagnostics.
1583+
* Tests API reachability, authentication, campaign access, and system match.
1584+
* Shows per-step results inline.
15851585
* @private
15861586
*/
15871587
async _onTestConnection() {
@@ -1598,58 +1598,121 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15981598
const campaignId = campInput?.value?.trim();
15991599

16001600
if (!url || !key || !campaignId) {
1601-
if (resultEl) {
1602-
resultEl.textContent = 'Fill in URL, key, and campaign ID first.';
1603-
resultEl.className = 'config-test-result test-error';
1604-
}
1601+
this._renderTestResults(resultEl, [
1602+
{ icon: 'xmark', text: 'Fill in URL, key, and campaign ID first.' },
1603+
]);
16051604
return;
16061605
}
16071606

16081607
if (resultEl) {
1609-
resultEl.textContent = 'Testing...';
1608+
resultEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Testing...';
16101609
resultEl.className = 'config-test-result test-pending';
16111610
}
16121611

1612+
const baseUrl = url.replace(/\/+$/, '');
1613+
const steps = [];
1614+
1615+
// Step 1: API reachable + auth + campaign
1616+
let resp;
16131617
try {
1614-
const testUrl = `${url.replace(/\/+$/, '')}/api/v1/campaigns/${campaignId}/entity-types`;
1615-
const resp = await fetch(testUrl, {
1618+
resp = await fetch(`${baseUrl}/api/v1/campaigns/${campaignId}/entity-types`, {
16161619
method: 'GET',
1617-
headers: {
1618-
'Authorization': `Bearer ${key}`,
1619-
'Accept': 'application/json',
1620-
},
1620+
headers: { 'Authorization': `Bearer ${key}`, 'Accept': 'application/json' },
16211621
});
1622-
1623-
if (resp.ok) {
1624-
if (resultEl) {
1625-
resultEl.textContent = 'Connected successfully!';
1626-
resultEl.className = 'config-test-result test-success';
1627-
}
1622+
} catch (err) {
1623+
const isCors = err instanceof TypeError && /failed to fetch|networkerror/i.test(err.message || '');
1624+
if (isCors) {
1625+
steps.push({ icon: 'xmark', text: `CORS: Chronicle is blocking requests from this domain. Ask admin to add ${window.location.origin} to CORS whitelist.` });
16281626
} else {
1629-
const status = resp.status;
1630-
let hint = '';
1631-
if (status === 401 || status === 403) hint = 'Check API key and permissions.';
1632-
else if (status === 404) hint = 'Check campaign ID.';
1633-
else hint = `HTTP ${status}`;
1634-
if (resultEl) {
1635-
resultEl.textContent = `Connection failed: ${hint}`;
1636-
resultEl.className = 'config-test-result test-error';
1637-
}
1627+
steps.push({ icon: 'xmark', text: `Unreachable: Chronicle server not responding at ${baseUrl}. Check URL and server status.` });
16381628
}
1639-
} catch (err) {
1640-
if (resultEl) {
1641-
// Browsers throw a TypeError with generic message on CORS failure.
1642-
const isCors = err instanceof TypeError
1643-
&& /failed to fetch|networkerror/i.test(err.message || '');
1644-
if (isCors) {
1645-
resultEl.textContent = 'CORS error: The Chronicle server is not allowing requests from this Foundry instance. '
1646-
+ 'Add this origin to the server\'s CORS allowed origins.';
1647-
} else {
1648-
resultEl.textContent = `Connection error: ${err.message}`;
1629+
this._renderTestResults(resultEl, steps);
1630+
return;
1631+
}
1632+
1633+
if (resp.status === 401) {
1634+
steps.push({ icon: 'check', text: 'API reachable' });
1635+
steps.push({ icon: 'xmark', text: 'Auth failed: API key invalid or revoked. Create a new key in Chronicle Settings \u2192 Integrations.' });
1636+
this._renderTestResults(resultEl, steps);
1637+
return;
1638+
}
1639+
if (resp.status === 403) {
1640+
steps.push({ icon: 'check', text: 'API reachable' });
1641+
steps.push({ icon: 'xmark', text: 'Forbidden: API key lacks required permissions. Check Read/Write/Sync are enabled.' });
1642+
this._renderTestResults(resultEl, steps);
1643+
return;
1644+
}
1645+
if (resp.status === 404) {
1646+
steps.push({ icon: 'check', text: 'API reachable' });
1647+
steps.push({ icon: 'check', text: 'Auth OK' });
1648+
steps.push({ icon: 'xmark', text: `Campaign not found. Check campaign ID: ${campaignId}` });
1649+
this._renderTestResults(resultEl, steps);
1650+
return;
1651+
}
1652+
if (resp.status === 429) {
1653+
steps.push({ icon: 'check', text: 'API reachable' });
1654+
steps.push({ icon: 'xmark', text: 'Rate limit exceeded. Wait a moment or ask admin to increase the limit.' });
1655+
this._renderTestResults(resultEl, steps);
1656+
return;
1657+
}
1658+
if (!resp.ok) {
1659+
steps.push({ icon: 'check', text: 'API reachable' });
1660+
steps.push({ icon: 'xmark', text: `HTTP ${resp.status}: ${resp.statusText}` });
1661+
this._renderTestResults(resultEl, steps);
1662+
return;
1663+
}
1664+
1665+
steps.push({ icon: 'check', text: 'API reachable' });
1666+
steps.push({ icon: 'check', text: 'Auth OK' });
1667+
steps.push({ icon: 'check', text: 'Campaign accessible' });
1668+
1669+
// Step 2: System match check
1670+
try {
1671+
const sysResp = await fetch(`${baseUrl}/api/v1/campaigns/${campaignId}/systems`, {
1672+
method: 'GET',
1673+
headers: { 'Authorization': `Bearer ${key}`, 'Accept': 'application/json' },
1674+
});
1675+
if (sysResp.ok) {
1676+
const sysResult = await sysResp.json();
1677+
const systems = sysResult.data || [];
1678+
const foundryId = game.system?.id;
1679+
if (foundryId) {
1680+
const match = systems.find(s => s.foundry_system_id === foundryId && s.enabled);
1681+
if (match) {
1682+
steps.push({ icon: 'check', text: `System matched: ${match.name}` });
1683+
} else {
1684+
const byId = systems.find(s => s.foundry_system_id === foundryId);
1685+
if (byId && !byId.enabled) {
1686+
steps.push({ icon: 'warn', text: `System "${byId.name}" found but not enabled for this campaign` });
1687+
} else if (systems.length > 0) {
1688+
steps.push({ icon: 'warn', text: `No system matches Foundry "${foundryId}". Available: ${systems.map(s => s.foundry_system_id || '(none)').join(', ')}` });
1689+
} else {
1690+
steps.push({ icon: 'warn', text: 'No game systems installed in Chronicle' });
1691+
}
1692+
}
16491693
}
1650-
resultEl.className = 'config-test-result test-error';
16511694
}
1652-
}
1695+
} catch { /* system check is optional */ }
1696+
1697+
this._renderTestResults(resultEl, steps);
1698+
}
1699+
1700+
/**
1701+
* Render multi-step test connection results.
1702+
* @param {HTMLElement} resultEl
1703+
* @param {Array<{icon: string, text: string}>} steps
1704+
* @private
1705+
*/
1706+
_renderTestResults(resultEl, steps) {
1707+
if (!resultEl) return;
1708+
const hasError = steps.some(s => s.icon === 'xmark');
1709+
const icons = {
1710+
check: '<i class="fa-solid fa-check-circle" style="color:#4ade80"></i>',
1711+
xmark: '<i class="fa-solid fa-circle-xmark" style="color:#f87171"></i>',
1712+
warn: '<i class="fa-solid fa-triangle-exclamation" style="color:#fbbf24"></i>',
1713+
};
1714+
resultEl.innerHTML = steps.map(s => `<div>${icons[s.icon] || ''} ${s.text}</div>`).join('');
1715+
resultEl.className = `config-test-result ${hasError ? 'test-error' : 'test-success'}`;
16531716
}
16541717

16551718
/**

styles/chronicle-sync.css

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,14 +1178,19 @@
11781178
.config-test-result {
11791179
font-size: 12px;
11801180
transition: color 0.2s;
1181+
margin-top: 4px;
1182+
}
1183+
1184+
.config-test-result > div {
1185+
padding: 1px 0;
11811186
}
11821187

11831188
.config-test-result.test-success {
1184-
color: #4ade80;
1189+
color: #d1d5db;
11851190
}
11861191

11871192
.config-test-result.test-error {
1188-
color: #f87171;
1193+
color: #d1d5db;
11891194
}
11901195

11911196
.config-test-result.test-pending {

templates/sync-dashboard.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
<button type="button" class="dashboard-btn btn-sm" data-action="test-connection">
9393
<i class="fa-solid fa-plug"></i> Test Connection
9494
</button>
95-
<span class="config-test-result" data-test-result></span>
95+
<div class="config-test-result" data-test-result></div>
9696
</div>
9797
</div>
9898

0 commit comments

Comments
 (0)