Add per-row connectivity diagnostic refresh controls
This commit is contained in:
@@ -18,6 +18,15 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
|
||||
7. Implement `prefers-reduced-motion` alongside every new animation.
|
||||
8. Run `npm test`, `npm run build`, and `git diff --check`. Perform manual visual inspection only when explicitly requested.
|
||||
|
||||
## Communicating a proposed change
|
||||
|
||||
For implementation proposals and progress summaries, combine structure instead of maximizing it:
|
||||
|
||||
- start with one compact table grouped by affected modules such as frontend, UI, backend/API, styles, tests, and versions;
|
||||
- use short lists only for cross-cutting details such as states, accessibility, motion, or changed files;
|
||||
- keep unaffected modules visible in the table when that clarifies scope;
|
||||
- avoid both an unstructured paragraph and a separate table for every subsection.
|
||||
|
||||
## Non-negotiable decisions
|
||||
|
||||
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis.
|
||||
|
||||
@@ -4,4 +4,6 @@ Use the checked-in `workpack/` directory as the only roadmap source. Do not requ
|
||||
|
||||
Follow `workpack/AGENTS.md` for every roadmap task, including status updates. Completed tasks must not be selected or implemented again unless the user explicitly asks to reopen one.
|
||||
|
||||
Before implementing any feature, record or refresh its plan in the selected `workpack/tasks/TASK-*.md` file using the mandatory feature-plan contract from `workpack/AGENTS.md`. Write the whole plan in simple language understandable without knowledge of the codebase: explain technical terms on first use, and use file paths or code names only as supporting detail. The plan must explain the implementation sequence and affected system components. For user-visible work it must also specify layout and states, exact icons, animation/motion behavior, accessibility and reduced-motion behavior; otherwise it must explicitly state that UI, icons and motion are unaffected. A proposed visible design is not owner approval.
|
||||
|
||||
For every runtime, UI, API, dependency or deployment-config change, use `.codex/skills/manage-harbor-versions/SKILL.md`. Before completion, classify the affected components, bump the required version level and run `npm run version:harbor -- check <base>`. Documentation- and test-only changes do not require a bump.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.26.6',
|
||||
gatewayClient: '0.27.6',
|
||||
macClient: '0.26.7',
|
||||
gatewayClient: '0.27.7',
|
||||
gatewayBackend: '0.27.0',
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,34 @@ function Status({ value, route }: { value: StatusValue; route: string }) {
|
||||
</span>;
|
||||
}
|
||||
|
||||
function RowRefresh({
|
||||
label,
|
||||
running,
|
||||
disabled,
|
||||
onRun,
|
||||
}: {
|
||||
label: string;
|
||||
running: boolean;
|
||||
disabled: boolean;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return <span className="client-diagnostics-row-refresh-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-diagnostics-refresh client-diagnostics-row-refresh${running ? ' is-running' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Проверить: ${label}`}
|
||||
aria-busy={running}
|
||||
disabled={disabled}
|
||||
onClick={onRun}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>Проверить только эту строку</Tooltip>
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ipResult(path: DiagnosticPath | undefined, source: IpSourceDefinition) {
|
||||
if (!path?.available) return null;
|
||||
return source.family === 6
|
||||
@@ -222,6 +250,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const runnerRef = useRef<HTMLSpanElement>(null);
|
||||
const previousTargetRef = useRef<string | null>(null);
|
||||
const retryTargetRef = useRef<string | undefined>(undefined);
|
||||
const requestError = requestDetails(error);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -264,12 +293,13 @@ export function ConnectivityDiagnosticsPanel({
|
||||
previousTargetRef.current = activeTarget;
|
||||
}, [activeTarget]);
|
||||
|
||||
async function run() {
|
||||
async function run(onlyTarget?: string) {
|
||||
retryTargetRef.current = onlyTarget;
|
||||
setStatus('running');
|
||||
setError(null);
|
||||
try {
|
||||
let next = result;
|
||||
const targets = [
|
||||
const targets = onlyTarget ? [onlyTarget] : [
|
||||
CONNECTIVITY_NETWORK_SOURCE.id,
|
||||
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
|
||||
...sites.map(({ id }) => `site:${id}`),
|
||||
@@ -379,7 +409,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
aria-label="Проверить маршруты"
|
||||
aria-busy={pending}
|
||||
disabled={pending}
|
||||
onClick={run}
|
||||
onClick={() => run()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
@@ -393,7 +423,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
{Boolean(error) && <div className="client-diagnostics-feedback">
|
||||
<div className="client-diagnostics-error" role="alert">
|
||||
<span>{requestError.message}</span>
|
||||
{requestError.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||
{requestError.retryable && <button type="button" onClick={() => run(retryTargetRef.current)}>Повторить</button>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -412,7 +442,15 @@ export function ConnectivityDiagnosticsPanel({
|
||||
data-diagnostic-target={CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
className={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id ? 'is-running' : undefined}
|
||||
>
|
||||
<th scope="row">{CONNECTIVITY_NETWORK_SOURCE.label}</th>
|
||||
<th scope="row" aria-label={CONNECTIVITY_NETWORK_SOURCE.label}><span className="client-diagnostics-row-name">
|
||||
<span>{CONNECTIVITY_NETWORK_SOURCE.label}</span>
|
||||
<RowRefresh
|
||||
label={CONNECTIVITY_NETWORK_SOURCE.label}
|
||||
running={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
disabled={pending}
|
||||
onRun={() => run(CONNECTIVITY_NETWORK_SOURCE.id)}
|
||||
/>
|
||||
</span></th>
|
||||
<td><NetworkCell
|
||||
path={result?.direct}
|
||||
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
@@ -428,7 +466,15 @@ export function ConnectivityDiagnosticsPanel({
|
||||
const target = `ip:${source.id}`;
|
||||
const running = activeTarget === target;
|
||||
return <tr key={source.id} data-diagnostic-target={target} className={running ? 'is-running' : undefined}>
|
||||
<th scope="row">{source.label}</th>
|
||||
<th scope="row" aria-label={source.label}><span className="client-diagnostics-row-name">
|
||||
<span>{source.label}</span>
|
||||
<RowRefresh
|
||||
label={source.label}
|
||||
running={running}
|
||||
disabled={pending}
|
||||
onRun={() => run(target)}
|
||||
/>
|
||||
</span></th>
|
||||
<td><IpCell path={result?.direct} source={source} pending={running} route={`Напрямую, ${source.label}`} /></td>
|
||||
<td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td>
|
||||
</tr>})}</tbody>
|
||||
@@ -460,7 +506,15 @@ export function ConnectivityDiagnosticsPanel({
|
||||
style={{ viewTransitionName: removing ? 'none' : `diagnostic-service-${site.id}` }}
|
||||
inert={removing ? true : undefined}
|
||||
>
|
||||
<span role="rowheader" className="client-diagnostics-service-name">{site.label}</span>
|
||||
<span role="rowheader" aria-label={site.label} className="client-diagnostics-service-name client-diagnostics-row-name">
|
||||
<span>{site.label}</span>
|
||||
<RowRefresh
|
||||
label={site.label}
|
||||
running={running}
|
||||
disabled={pending}
|
||||
onRun={() => run(`site:${site.id}`)}
|
||||
/>
|
||||
</span>
|
||||
<span role="cell"><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></span>
|
||||
<span role="cell"><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></span>
|
||||
<button
|
||||
|
||||
@@ -79,6 +79,31 @@
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.client-diagnostics-row-name {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.client-diagnostics-row-name > span:first-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.client-diagnostics-row-refresh-wrap,
|
||||
.client-diagnostics-row-refresh {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.client-diagnostics-row-refresh svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.client-diagnostics-feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -83,7 +83,7 @@ test('unknown target and legacy-full results pass one identity-preserving parser
|
||||
});
|
||||
|
||||
test('serial probes, storage and editor behavior stay panel-owned', () => {
|
||||
assert.match(panel, /const targets = \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
|
||||
assert.match(panel, /const targets = onlyTarget \? \[onlyTarget\] : \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
|
||||
assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(customServices, target\)[\s\S]*if \(legacyFullResult\) break/);
|
||||
assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/);
|
||||
assert.match(panel, /HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'/);
|
||||
@@ -94,6 +94,20 @@ test('serial probes, storage and editor behavior stay panel-owned', () => {
|
||||
assert.match(panel, /requestDetails\(error\)[\s\S]*requestError\.retryable/);
|
||||
});
|
||||
|
||||
test('each visible row reuses the targeted probe without adding another result owner', () => {
|
||||
assert.match(panel, /function RowRefresh\([\s\S]*aria-label=\{`Проверить: \$\{label\}`\}[\s\S]*aria-busy=\{running\}[\s\S]*disabled=\{disabled\}/);
|
||||
assert.match(panel, /<Tooltip>Проверить только эту строку<\/Tooltip>/);
|
||||
assert.match(panel, /async function run\(onlyTarget\?: string\)[\s\S]*const targets = onlyTarget \? \[onlyTarget\] :/);
|
||||
assert.match(panel, /onRun=\{\(\) => run\(CONNECTIVITY_NETWORK_SOURCE\.id\)\}/);
|
||||
assert.match(panel, /onRun=\{\(\) => run\(target\)\}/);
|
||||
assert.match(panel, /onRun=\{\(\) => run\(`site:\$\{site\.id\}`\)\}/);
|
||||
assert.match(panel, /retryTargetRef\.current = onlyTarget[\s\S]*run\(retryTargetRef\.current\)/);
|
||||
assert.match(panel, /scope="row" aria-label=\{CONNECTIVITY_NETWORK_SOURCE\.label\}/);
|
||||
assert.match(panel, /role="rowheader" aria-label=\{site\.label\}/);
|
||||
assert.equal((panel.match(/className="client-diagnostics-row-name"/g) || []).length, 2);
|
||||
assert.match(panel, /client-diagnostics-service-name client-diagnostics-row-name/);
|
||||
});
|
||||
|
||||
test('network identity is one stable compact row for Direct and VPN', () => {
|
||||
assert.match(panel, /data-diagnostic-target=\{CONNECTIVITY_NETWORK_SOURCE\.id\}[\s\S]*<NetworkCell[\s\S]*route="Напрямую, сеть"[\s\S]*<NetworkCell[\s\S]*route="VPN, сеть"/);
|
||||
assert.match(panel, /path\?\.network\?\.asn[\s\S]*path\?\.network\?\.provider[\s\S]*path\?\.network\?\.city[\s\S]*path\?\.network\?\.country/);
|
||||
|
||||
@@ -210,6 +210,9 @@ test('connectivity diagnostics render stable compact tables before the first run
|
||||
assert.match(diagnostics, /client-deletable-row[\s\S]*className="client-delete-strike"[\s\S]*onAnimationEnd/);
|
||||
assert.match(diagnostics, /document\.startViewTransition\(update\)/);
|
||||
assert.match(diagnostics, /client-diagnostics-refresh/);
|
||||
assert.match(diagnostics, /client-diagnostics-row-refresh/);
|
||||
assert.match(rule('.client-diagnostics-row-name'), /display:\s*inline-flex[\s\S]*max-width:\s*100%/);
|
||||
assert.match(styles, /\.client-diagnostics-row-refresh-wrap,[\s\S]*flex:\s*0 0 24px/);
|
||||
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
|
||||
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
||||
assert.match(diagnostics, /\{Boolean\(error\) && <div className="client-diagnostics-feedback"/);
|
||||
@@ -222,6 +225,7 @@ test('connectivity diagnostics render stable compact tables before the first run
|
||||
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
|
||||
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
|
||||
assert.match(diagnostics, /data-diagnostic-target=\{target\}/);
|
||||
assert.match(diagnostics, /const targets = onlyTarget \? \[onlyTarget\] :/);
|
||||
assert.match(diagnostics, /client-diagnostics-active-marker/);
|
||||
assert.match(styles, /\.client-diagnostics-active-marker\.is-moving \{[\s\S]*transform 680ms/);
|
||||
assert.match(diagnostics, />\.{3}<\/span>/);
|
||||
|
||||
@@ -37,26 +37,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 949,
|
||||
cascadeEdges: 954,
|
||||
customProperties: 106,
|
||||
declarations: 3474,
|
||||
declarations: 3486,
|
||||
important: 0,
|
||||
keyframes: 48,
|
||||
media: 13,
|
||||
rules: 966,
|
||||
rules: 970,
|
||||
variableReferences: 837,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'a2289354130ffe83c907b977d21d773799c4435c8016e51d14f153184f834804',
|
||||
cascadeEdges: 'd53f6a2236717d6bc27d486fc19f0f80a33169fa7f68a6df2a19536fd30be3bb',
|
||||
customProperties: 'fd6f16069f9fe3526f09d4d574431ddae67150d8e7a8a40e04c9fd2d179ec7ac',
|
||||
declarations: '6581f90631086ad0ba2ef1b3d1ec86f608e872572af5486d4c7199f06802bcfd',
|
||||
declarations: '1939d4a9a8b812ea799fbbb22a72befde73aa20159ef6736c9c35a5b3458d32c',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: 'a0d69c5b3e5f235d3a76ed04bdd64f67a73fafc9c59fe2635ffda603709c0ad9',
|
||||
ruleDeclarationSequences: 'dc793f9ea84540c87ae3650fa915efed643b941c393e8dd0fd75fcb01c543bb3',
|
||||
selectors: 'a556fe1d9608d73332db5b3c14cd4cfde42f3346a542d3e2b2ce2af34f556c8c',
|
||||
ruleDeclarationSequences: '0a7655c97f5deb132f4c05d136e4d615a0abbbb9e86ab83c8155047bc409bfec',
|
||||
selectors: '0c1374fd3f414faac30f1519e6f0b04f63fc50aa69a05a84d53fbe33c426eccc',
|
||||
variableReferences: '843a5f71b0fb74691ed623dbc7ebb7ddddc34e5736e1f419f17cf54e22d43073',
|
||||
witnesses: '10b015073632f8697ac16b5849f28029322d9164060896c366ba4d35dbb3cee5',
|
||||
witnesses: '83b49149eecdf0a67348412f7cfd49023aad76848b1572d25d6c0e9193e612d5',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 860);
|
||||
assert.equal(witnesses.length, 880);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-DUSJhcRO.css']);
|
||||
assert.deepEqual(assets, ['index-DDl9J3Se.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 133278);
|
||||
assert.equal(sha256(built), 'f3116a2f0f282a10030589057ec0c62800640b7b552180dfe12d4b737a5fc436');
|
||||
assert.equal(built.byteLength, 133627);
|
||||
assert.equal(sha256(built), '68bf7b36cf345bfb460b7f6c9d973bde54e260a0c695176d035c1fe02ba0f3a1');
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
|
||||
.map(({ source }) => source)
|
||||
.join('\n');
|
||||
|
||||
assert.equal((production.match(/<Tooltip\b/g) || []).length, 13);
|
||||
assert.equal((production.match(/<Tooltip\b/g) || []).length, 14);
|
||||
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 5);
|
||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 5);
|
||||
|
||||
Reference in New Issue
Block a user