Add per-row connectivity diagnostic refresh controls
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-19 12:59:21 +03:00
parent 7cb25d0633
commit df865fbe3d
9 changed files with 131 additions and 23 deletions
@@ -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.
+2
View File
@@ -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.
+2 -2
View File
@@ -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
+25
View File
@@ -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;
+15 -1
View File
@@ -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>/);
+12 -12
View File
@@ -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');
});
+1 -1
View File
@@ -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);