Files
shater/panel/src/pages/Nodes.css
T
omarandClaude Opus 5 1746d4d0ef
release / apk aarch64_cortex-a53 (push) Successful in 9m13s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 7s
fix: stop the panel and the shipped binary from lying about what works
Four defects, all found by the owner on the live router, all of the same
family: something declared itself working while it was not.

WIREGUARD WAS DEAD IN THE SHIPPED BINARY (B17). Setting up WireGuard gave
"create WireGuard device: gVisor is not included in this build". The router
tag set carried with_wireguard and with_awg but not with_gvisor, so
sing-tun compiled its stub instead of the netstack every WireGuard device
needs. FEATURES.md marks WireGuard [MVP] and AmneziaWG "a driving
requirement", so this was a broken promise, not a trim.

The tag itself was the small half. The tag set was the ONE build
configuration nothing in the repo tested: TestAmneziaWGEndpoint passes
because tests build with the full upstream tags. So the set now lives in
one file (scripts/router-tags.sh) and two guards hold it to the feature
list -- a static check that needs no tags, no Linux and no network (so the
next such gap fails on the developer's machine), and a behavioural one that
constructs every declared protocol through box.New UNDER THE SHIPPED TAGS,
where skipping is forbidden. Removing the tag now fails with the feature
name, the missing tag, and why: "Either add the tag back, or stop declaring
the feature -- those are the only two honest options." Cost: +2.8 MB raw,
+0.6-0.7 MB packed per arch. D23; D9 corrected.

THE PANEL CALLED A DIRECT-ONLY ROUTER "PROTECTED" (B16). The headline came
from plane === 'full', which reports whether the data plane is installed --
nft table, policy routing, live engine -- and says nothing about where the
traffic goes. On a config with one `default -> direct` rule and no groups
the plane is fully installed and every packet leaves in the clear, so the
worst possible state rendered as the reassuring one.

The verdict is now computed on the daemon FROM THE GENERATED OPTIONS at the
moment they reach the engine, not from the model: buildRoute changes the
answer (a scheduled rule outside its window is never emitted, only the last
condition-less rule reaches Final, an unresolved target is rewritten by
ruleKillFallback), and re-deriving it anywhere else is a second
implementation that will drift -- model/reachability.go exists because two
already did. Four verdicts, not three: `blocked` is separate because under
a closed kill-switch with no catch-all nothing leaks, and calling that
"going out directly" is a lie in the alarm direction. Rider: Overview's
defaultTarget printed the highest-Order enabled rule as the default; a rule
becomes Final by having no conditions, whatever its Order.

"PREVENT THIS PAGE FROM CREATING ADDITIONAL DIALOGS" KILLED EVERY DELETE
(B15). Once the browser suppresses dialogs, window.confirm returns false
immediately, so all 15 confirmations across 7 pages read as "cancelled" and
silently did nothing, with no way to recover from inside the panel. Replaced
with an in-app dialog the browser cannot mute: focus trapped and parked on
Cancel, Esc and veil cancel, focus returned to the opener, crit styling for
destructive commits. useConfirm() throws if the provider is missing rather
than falling back to a quiet false -- the failure mode being fixed.

HYSTERIA2 AND TUIC NODES WERE DROPPED (B6). No share-link parser existed,
so a feed's nodes of those types vanished. The real landmine was one layer
up: ParseSubscriptionBody splits a feed by scheme prefix before parsing, so
without schemePrefixes the links were gone before any parser ran and the
fix would have looked complete. Undeliverable parameters are refused when
the node cannot work or would be less secure than the link asked (obfs,
pinSHA256, tuic v4/non-UUID) and flagged via Proxy.Warnings when it
survives -- shaterd nodes shows both. uTLS is dropped for QUIC: it cannot
produce a QUIC TLS config, and that fails at dial time, not at box.New.

Also: nodes added by hand can be named and renamed. The name is the
outbound tag, so a rename rewrites every reference in one PUT -- rule
targets, group members, chain hops, detours -- in the spelling each already
uses, and is refused outright when a group answers to the same bare name.
Subscription nodes state why they cannot be renamed instead of hiding the
control.

go build, go vet, go test ./shater/... (13 packages), panel npm run build
and npm test (13/13) all green. NOT yet verified on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 20:08:58 +03:00

780 lines
16 KiB
CSS

/* Nodes & Subscriptions page — inherits the Faceplate tokens and the shared
* page chrome from App.css (.page, .toast, .page-error, .linkish). Only the
* list/table + inline-editor specifics live here. Orange stays an accent. */
.nodes-page {
display: flex;
flex-direction: column;
}
/* ---- saved-but-not-applied banner ---- */
.apply-banner {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
margin-top: calc(var(--u, 8px) * 2);
padding: 10px 12px 10px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--groove));
border-radius: 8px;
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 9%, var(--raised)), var(--raised));
box-shadow: 0 1px 0 var(--edge) inset;
}
.apply-banner-msg {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.02em;
color: var(--ink);
}
/* ---- section ---- */
.node-section {
margin-top: calc(var(--u, 8px) * 3.5);
}
.sec-hd {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--groove);
}
.sec-title {
margin: 0;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 700;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--dim);
}
.sec-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--faint);
}
/* Where health went, and why. This page is inventory; reachability is measured
per group, on the path that group actually dials. */
.node-note {
margin: 10px 2px 0;
font-family: var(--font-sans);
font-size: 12.5px;
line-height: 1.55;
color: var(--dim);
max-width: 68ch;
}
/* ---- add row (inline editor) ---- */
.add-row {
display: flex;
gap: calc(var(--u, 8px) * 1.25);
margin-top: calc(var(--u, 8px) * 2);
}
.add-row--sub {
flex-wrap: wrap;
}
.fp-input {
flex: 1;
min-width: 0;
padding: 9px 12px;
border: 1px solid var(--groove);
border-radius: 7px;
background: var(--sink);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12.5px;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px var(--shadow) inset;
transition: border-color 0.15s, box-shadow 0.15s;
}
.fp-input::placeholder {
color: var(--faint);
}
.fp-input:focus-visible {
border-color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.fp-input:disabled {
opacity: 0.55;
}
.fp-input--name {
flex: 0 1 12rem;
}
.field-err {
margin: 8px 2px 0;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.5;
color: var(--crit);
}
/* ---- rows list ---- */
.rows-list {
list-style: none;
margin: calc(var(--u, 8px) * 2) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row-item {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
padding: 12px 14px;
border: 1px solid var(--groove);
border-radius: 8px;
background: linear-gradient(
180deg,
var(--raised),
color-mix(in srgb, var(--raised) 82%, var(--panel))
);
box-shadow: 0 1px 0 var(--edge) inset;
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row-line1 {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.row-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 22ch;
}
.row-line2 {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 11.5px;
letter-spacing: 0.02em;
}
.row-host {
color: var(--dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 30ch;
}
.row-origin {
color: var(--faint);
text-transform: lowercase;
}
/* protocol / format pill — groove-bordered, not orange (accent stays reserved) */
.badge {
display: inline-block;
padding: 2px 7px;
border: 1px solid var(--groove);
border-radius: 5px;
background: color-mix(in srgb, var(--sink) 60%, transparent);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--dim);
white-space: nowrap;
}
.badge--warn {
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
color: var(--amber);
}
/* masked-credential marker */
.masked {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--faint);
text-transform: uppercase;
cursor: help;
}
/* per-row controls */
.row-actions {
display: flex;
align-items: center;
gap: 8px;
}
.row-del {
flex: none;
padding: 6px 12px;
font-size: 10.5px;
}
.row-lock {
flex: none;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
cursor: default;
}
/* (The per-node health LED + latency chip lived here. A node's base outbound is
not what a group bound to an egress dials, so that column reported one of a
node's several health states and called it the truth. Health is per group now:
see .gh-* in Targets.css.) */
/* ---- empty plate ---- */
.empty-plate {
margin-top: calc(var(--u, 8px) * 2);
padding: calc(var(--u, 8px) * 3);
border: 1px dashed var(--groove);
border-radius: 9px;
background: color-mix(in srgb, var(--raised) 55%, transparent);
text-align: center;
}
.empty-title {
display: block;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--dim);
}
.empty-body {
margin: 8px auto 0;
max-width: 44ch;
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.55;
color: var(--dim);
}
/* ---- loading skeleton ---- */
.skel-row {
height: 62px;
border: 1px solid var(--groove);
border-radius: 8px;
background: linear-gradient(90deg, var(--raised), var(--sink), var(--raised));
background-size: 200% 100%;
animation: skel-shift 1.4s ease-in-out infinite;
}
@keyframes skel-shift {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* ---- expandable subscription / node row + options editor ---- */
.sub-row,
.node-row {
flex-direction: column;
align-items: stretch;
}
.row-head {
display: flex;
align-items: center;
gap: calc(var(--u, 8px) * 1.5);
}
.sub-row--open,
.node-row--open {
border-color: color-mix(in srgb, var(--accent) 32%, var(--groove));
}
/* collapsed-row egress binding marker — a muted mono chip so the "dial via
<egress>" pin is legible without expanding the row. Distinct from the plain
"via <sub>" origin text: bordered, tinted, and carries a hover explanation. */
.egress-chip {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--groove));
border-radius: 5px;
background: color-mix(in srgb, var(--accent) 8%, transparent);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--dim);
white-space: nowrap;
cursor: help;
}
/* one-field egress picker under a node row — leans on the shared .opt-field /
.opt-actions vocabulary from the subscription editor. */
.node-egress {
margin-top: 12px;
padding-top: 14px;
border-top: 1px solid var(--groove);
display: flex;
flex-direction: column;
gap: 14px;
}
.sub-options {
margin-top: 12px;
padding-top: 14px;
border-top: 1px solid var(--groove);
display: flex;
flex-direction: column;
gap: 14px;
}
/* a labelled group of options */
.opt-group {
margin: 0;
padding: 12px 12px 14px;
border: 1px solid var(--groove);
border-radius: 8px;
background: color-mix(in srgb, var(--sink) 45%, transparent);
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.opt-group:disabled {
opacity: 0.6;
}
.opt-legend {
padding: 0 6px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--faint);
}
.opt-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 12px;
}
.opt-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.opt-field--wide {
grid-column: 1 / -1;
}
.opt-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
}
.opt-hint {
font-family: var(--font-sans);
font-size: 11px;
line-height: 1.45;
color: var(--faint);
}
/* selects inherit .fp-input; give them a little room for the native arrow */
select.fp-input {
appearance: none;
padding-right: 26px;
cursor: pointer;
}
/* extra-headers key/value rows */
.hdr-field {
gap: 8px;
}
.hdr-rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.hdr-row {
display: grid;
grid-template-columns: minmax(9rem, 1fr) 2fr auto;
gap: 8px;
align-items: center;
}
.hdr-key {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.hdr-x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--faint);
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.hdr-x:hover:not(:disabled) {
color: var(--crit);
background: color-mix(in srgb, var(--crit) 14%, transparent);
}
.hdr-x:disabled {
opacity: 0.5;
cursor: default;
}
/* editor footer */
.opt-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.opt-status {
font-size: 11px;
letter-spacing: 0.04em;
color: var(--faint);
}
/* ---- responsive ---- */
@media (max-width: 640px) {
.row-item {
flex-wrap: wrap;
}
.row-head {
flex-wrap: wrap;
}
.row-main {
flex-basis: calc(100% - 90px);
}
.row-actions,
.row-del,
.row-lock {
margin-left: auto;
}
.fp-input--name {
flex-basis: 100%;
}
.opt-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.skel-row {
animation: none;
}
}
/* ---- add-node: mode segmented control + textarea ---- */
.add-node {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: calc(var(--u, 8px) * 2);
}
.add-node .add-row {
margin-top: 0;
}
.seg {
display: inline-flex;
align-self: flex-start;
gap: 4px;
padding: 3px;
border: 1px solid var(--groove);
border-radius: 8px;
background: var(--sink);
box-shadow: 0 1px 2px var(--shadow) inset;
}
.seg-btn {
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--dim);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.seg-btn:hover:not(:disabled):not(.seg-btn--on) {
color: var(--ink);
}
.seg-btn--on {
background: color-mix(in srgb, var(--accent) 16%, var(--raised));
border-color: color-mix(in srgb, var(--accent) 40%, var(--groove));
color: var(--ink);
}
.seg-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.seg-btn:disabled {
opacity: 0.5;
cursor: default;
}
.fp-textarea {
min-height: 96px;
resize: vertical;
line-height: 1.5;
padding: 10px 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.add-row--conf {
flex-direction: column;
align-items: stretch;
}
.add-row--conf .btn {
align-self: flex-end;
}
.add-hint {
margin: 0 2px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
color: var(--faint);
}
/* ---- node search ---- */
.node-search {
display: flex;
align-items: center;
gap: 10px;
margin-top: calc(var(--u, 8px) * 2.5);
}
.search-count {
flex: none;
font-size: 11px;
letter-spacing: 0.06em;
color: var(--faint);
white-space: nowrap;
}
/* ---- collapsible node groups ---- */
.node-groups {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: calc(var(--u, 8px) * 2);
}
.node-group {
display: flex;
flex-direction: column;
}
.group-hd-wrap {
margin: 0;
}
.group-hd {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid var(--groove);
border-radius: 8px;
background: color-mix(in srgb, var(--sink) 40%, transparent);
color: var(--dim);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.group-hd:hover {
border-color: color-mix(in srgb, var(--accent) 30%, var(--groove));
background: color-mix(in srgb, var(--sink) 60%, transparent);
}
.group-hd:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.node-group--open .group-hd {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom-color: transparent;
}
.group-caret {
flex: none;
width: 0;
height: 0;
border-left: 5px solid var(--faint);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s ease;
}
.node-group--open .group-caret {
transform: rotate(90deg);
}
.group-name {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: var(--track-label);
text-transform: uppercase;
color: var(--dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-count {
flex: none;
font-size: 11px;
letter-spacing: 0.06em;
color: var(--faint);
}
.group-rows {
margin-top: 8px;
}
@media (max-width: 640px) {
.seg {
align-self: stretch;
}
.seg-btn {
flex: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.group-caret {
transition: none;
}
}
/* ---- provider-reported account state (quota + expiry) ----
Sits under the subscription row head as a quiet instrument strip. There is no
meter when the provider didn't report an allowance — a 0-of-0 bar would be a
fabricated fact, so that case reads as plain text instead. */
.sub-account {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
padding: 10px 14px 12px 62px;
border-top: 1px solid var(--groove);
}
.sub-acct-item {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.sub-acct-k {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: var(--track-label, 0.18em);
text-transform: uppercase;
color: var(--faint);
}
.sub-acct-v {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--ink);
}
.sub-acct-sub {
font-size: 11px;
color: var(--faint);
}
.sub-acct-meter {
display: block;
width: 11rem;
max-width: 100%;
height: 5px;
margin-top: 2px;
border-radius: 3px;
background: var(--seg-off);
overflow: hidden;
}
.sub-acct-fill {
display: block;
height: 100%;
border-radius: 3px;
background: var(--led-on);
}
.sub-acct-fill--amber {
background: var(--amber);
}
.sub-acct-fill--crit {
background: var(--crit);
}
@media (max-width: 560px) {
.sub-account {
padding-left: 14px;
gap: 10px 18px;
}
.sub-acct-meter {
width: 9rem;
}
}
/* ---- inline node rename ----
The pencil / input pair itself is shared (.inline-rename[-input] in App.css);
only the row-local sizing and the refusal message live here. A node name is
longer than a device name (it carries a protocol and a host), so the field is
given more room than the shared 24ch default. */
.node-name-input {
max-width: 32ch;
font-family: var(--font-mono);
font-size: 12.5px;
}
/* Why a rename was refused, pinned under the row it was typed in. Semantic crit:
the name did not change, and that must not be mistaken for a saved edit. */
.row-err {
margin: 2px 0 0;
font-size: 11.5px;
line-height: 1.45;
color: var(--crit);
max-width: 68ch;
}
/* Stated once per subscription bucket: the same rule the locked control in every
row carries, so the absent rename is explained before it is looked for. */
.group-note {
margin: 0;
padding: 8px 12px;
border: 1px solid var(--groove);
border-top: 0;
background: color-mix(in srgb, var(--sink) 25%, transparent);
font-size: 11.5px;
line-height: 1.5;
color: var(--faint);
}
/* The optional name sits beside the link input on a wide row and drops onto its
own line when the row can no longer hold both. */
.add-name {
flex: 0 1 22ch;
min-width: 12ch;
}
.add-row--conf .add-name {
flex: none;
align-self: stretch;
}
@media (max-width: 640px) {
.add-row {
flex-wrap: wrap;
}
.add-name {
flex: 1 1 100%;
}
}