mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f579b23f52 | ||
|
|
f0d3db3164 | ||
|
|
078a6c0db0 | ||
|
|
7af0453f34 |
No files matched your search
@@ -1,4 +0,0 @@
|
|||||||
- A layer that is only waiting for a key now ends the load chip on an amber
|
|
||||||
`KEY REQUIRED` instead of a red `LOAD FAILED`, so a keyless Environmental
|
|
||||||
first run no longer reports a failure while earthquakes load. A real error
|
|
||||||
still wins over a missing key (asturksever, #1600).
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- The CCTV Street View fallback serves only catalog cameras, at their catalog
|
||||||
|
position, heading, field of view and pitch, and ignores coordinates a
|
||||||
|
request supplies. It spends from the per-client Google budget
|
||||||
|
(`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120), so the Google key can no
|
||||||
|
longer be used as an unmetered Street View service (asturksever, #1602).
|
||||||
@@ -1276,7 +1276,10 @@ and follows redirects only on the camera's origin. Catalog image URLs, and
|
|||||||
redirects on those hosts, are pinned by parsed URL to the agency's image
|
redirects on those hosts, are pinned by parsed URL to the agency's image
|
||||||
directory. Every `/api/cctv` route refuses cross-site browser requests but
|
directory. Every `/api/cctv` route refuses cross-site browser requests but
|
||||||
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
||||||
request skips the key-bearing Street View fallback and gets the placeholder.
|
request skips the key-bearing Street View fallback and gets the placeholder. The
|
||||||
|
Street View fallback serves only catalog cameras, at the catalog position,
|
||||||
|
heading, field of view and pitch, never coordinates a request supplies, and it
|
||||||
|
shares the per-client Google budget (`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120).
|
||||||
|
|
||||||
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
|
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
|
||||||
keeps one camera per ground cell sized to the view, preferring a healthy feed,
|
keeps one camera per ground cell sized to the view, preferring a healthy feed,
|
||||||
|
|||||||
@@ -377,7 +377,9 @@
|
|||||||
"src/data/cctvFootprint.js",
|
"src/data/cctvFootprint.js",
|
||||||
"src/sources/cctvTypes.js",
|
"src/sources/cctvTypes.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js"
|
"src/i18n/marker.js",
|
||||||
|
"server/providers/common/rate-limit.js",
|
||||||
|
"src/sources/rateLimit.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
|||||||
+32
-16
@@ -19,7 +19,22 @@ import { admitSameSiteKeyless } from './common/same-site.js';
|
|||||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||||
import { createHlsPuller } from './cctv/stream.js';
|
import { createHlsPuller } from './cctv/stream.js';
|
||||||
import { googleServerApiKey } from './places/google-key.js';
|
import { googleServerApiKey } from './places/google-key.js';
|
||||||
|
import { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
|
||||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||||
|
let _streetViewRateLimiter;
|
||||||
|
/**
|
||||||
|
* Per-IP budget for the Street View fallback, on the same setting as the
|
||||||
|
* other Google routes (GEV_RATELIMIT_GOOGLE_PER_MIN; 0 disables it).
|
||||||
|
*/
|
||||||
|
function streetViewRateLimiter() {
|
||||||
|
if (_streetViewRateLimiter === undefined)
|
||||||
|
_streetViewRateLimiter = makeCostRateLimiter(
|
||||||
|
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
return _streetViewRateLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
|
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
|
||||||
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
|
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
|
||||||
@@ -509,13 +524,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
const source = sourceById.get(cameraId);
|
const source = sourceById.get(cameraId);
|
||||||
const label = url.searchParams.get('label') || source?.name || cameraId;
|
const label = url.searchParams.get('label') || source?.name || cameraId;
|
||||||
const city = url.searchParams.get('city') || source?.city || '';
|
const city = url.searchParams.get('city') || source?.city || '';
|
||||||
const lat = Number(url.searchParams.get('lat') || source?.lat);
|
|
||||||
const lon = Number(url.searchParams.get('lon') || source?.lon);
|
|
||||||
const heading = Number(
|
|
||||||
url.searchParams.get('heading') || source?.headingDeg,
|
|
||||||
);
|
|
||||||
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
|
|
||||||
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
|
|
||||||
|
|
||||||
// Only use server-registered upstream URLs — never accept client-supplied URLs
|
// Only use server-registered upstream URLs — never accept client-supplied URLs
|
||||||
// (prevents SSRF via ?upstream= query parameter)
|
// (prevents SSRF via ?upstream= query parameter)
|
||||||
@@ -549,18 +557,26 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
// request (whose client this server cannot see) gets the placeholder,
|
// request (whose client this server cannot see) gets the placeholder,
|
||||||
// as if no key were configured. Any forwarding header counts, even an
|
// as if no key were configured. Any forwarding header counts, even an
|
||||||
// empty one.
|
// empty one.
|
||||||
|
// It is also only for catalog cameras, framed from the catalog (never
|
||||||
|
// a spot the query names), within the per-IP Google budget.
|
||||||
const forwarded = PROXY_SIGNALS.some(
|
const forwarded = PROXY_SIGNALS.some(
|
||||||
(name) => req.headers?.[name] !== undefined,
|
(name) => req.headers?.[name] !== undefined,
|
||||||
);
|
);
|
||||||
const sv = forwarded
|
const googleBudget = googleServerApiKey()
|
||||||
? null
|
? streetViewRateLimiter()
|
||||||
: await streetViewFallback({
|
: null;
|
||||||
lat,
|
const sv =
|
||||||
lon,
|
forwarded ||
|
||||||
heading,
|
!source ||
|
||||||
fov,
|
(googleBudget && !googleBudget(clientKey(req)))
|
||||||
pitch,
|
? null
|
||||||
});
|
: await streetViewFallback({
|
||||||
|
lat: Number(source.lat),
|
||||||
|
lon: Number(source.lon),
|
||||||
|
heading: Number(source.headingDeg),
|
||||||
|
fov: Number(source.fovDeg),
|
||||||
|
pitch: Number(source.pitchDeg ?? -10),
|
||||||
|
});
|
||||||
if (sv?.ok) {
|
if (sv?.ok) {
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: 'degraded',
|
status: 'degraded',
|
||||||
|
|||||||
@@ -528,3 +528,30 @@ test('a forwarded same-origin request reaches every CCTV route but not the Stree
|
|||||||
assert.equal(direct.body, 'street-view');
|
assert.equal(direct.body, 'street-view');
|
||||||
assert.equal(streetViewCalls(), 1);
|
assert.equal(streetViewCalls(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Street View fallback frames catalog cameras only, from the catalog position', async (t) => {
|
||||||
|
const app = mount(t, ({ url }) =>
|
||||||
|
url.startsWith('https://maps.googleapis.com/')
|
||||||
|
? trackedUpstream({
|
||||||
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
|
body: 'street-view',
|
||||||
|
}).response
|
||||||
|
: upstreamResponse({ status: 503 }),
|
||||||
|
);
|
||||||
|
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||||
|
const streetView = () =>
|
||||||
|
app.requests.filter(({ url }) =>
|
||||||
|
url.startsWith('https://maps.googleapis.com/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unknown = await app.call('/frame/not-a-camera?lat=48.85&lon=2.29');
|
||||||
|
assert.equal(unknown.headers['X-CCTV-Source'], 'synthetic');
|
||||||
|
assert.deepEqual(streetView(), [], 'no Google call for an unknown id');
|
||||||
|
|
||||||
|
const steered = await app.call(
|
||||||
|
`/frame/${STILL.id}?lat=48.85&lon=2.29&heading=90`,
|
||||||
|
);
|
||||||
|
assert.equal(steered.headers['X-CCTV-Source'], 'streetview');
|
||||||
|
const location = new URL(streetView()[0].url).searchParams.get('location');
|
||||||
|
assert.equal(location, `${STILL.lat},${STILL.lon}`);
|
||||||
|
});
|
||||||
@@ -98,9 +98,15 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
|||||||
// for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile
|
// for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile
|
||||||
// promises both, so it turns on both, and the subcopy in index.html says so.
|
// promises both, so it turns on both, and the subcopy in index.html says so.
|
||||||
//
|
//
|
||||||
// Keyless, FIRMS's own row reads "KEY REQUIRED", and the quakes half of
|
// Keyless, FIRMS is honest where it counts — its own layer row reads
|
||||||
// the tile still delivers in full; the global status chip ends on KEY
|
// "UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED", and the quakes half of
|
||||||
// REQUIRED too (src/loadingFeedback.js) rather than LOAD FAILED.
|
// the tile still delivers in full. What is NOT honest is the GLOBAL status
|
||||||
|
// chip, which has no key-required terminal state and folds that row into
|
||||||
|
// "LOAD FAILED". That aggregation is the defect, not this preset: fixing it
|
||||||
|
// means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state
|
||||||
|
// machine shared by every layer and not a thing to refactor the night
|
||||||
|
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
|
||||||
|
// are judged on the layer row, which tells them the truth.
|
||||||
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
||||||
busyKey: msg('Scanning active events…'),
|
busyKey: msg('Scanning active events…'),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -606,10 +606,11 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
|
|||||||
const environmental = FIRST_RUN_MISSIONS.environmental;
|
const environmental = FIRST_RUN_MISSIONS.environmental;
|
||||||
assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']);
|
assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']);
|
||||||
|
|
||||||
// Keyless, the LAYER ROW reads "KEY REQUIRED", and the global chip now ends
|
// Keyless, the honest surface is the LAYER ROW ("KEY REQUIRED"), which the
|
||||||
// on KEY REQUIRED as well instead of LOAD FAILED (src/loadingFeedback.js,
|
// FIRMS layer already reports. The misleading part is the GLOBAL chip folding
|
||||||
// covered in loadingFeedback.test.mjs). The note says so where the next
|
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
|
||||||
// editor will read it.
|
// post-launch, and the note must stay where the next editor will read it
|
||||||
|
// rather than being re-discovered as a launcher bug.
|
||||||
const module = fs.readFileSync(
|
const module = fs.readFileSync(
|
||||||
new URL('./firstRunExperience.js', import.meta.url),
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -620,7 +621,7 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
|
|||||||
);
|
);
|
||||||
assert.match(table, /KEY REQUIRED/);
|
assert.match(table, /KEY REQUIRED/);
|
||||||
assert.match(table, /src\/loadingFeedback\.js/);
|
assert.match(table, /src\/loadingFeedback\.js/);
|
||||||
assert.match(table, /rather than LOAD FAILED/);
|
assert.match(table, /LEDGERED post-launch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every visitor gets the same tile — there is no degraded keyless variant', async () => {
|
test('every visitor gets the same tile — there is no degraded keyless variant', async () => {
|
||||||
|
|||||||
+7
-13
@@ -85,18 +85,13 @@ export function normalizeLayerLoading(layer = {}) {
|
|||||||
function terminalFromParticipantStats(summary, participantIds) {
|
function terminalFromParticipantStats(summary, participantIds) {
|
||||||
if (!participantIds?.length) return null;
|
if (!participantIds?.length) return null;
|
||||||
const participants = new Set(participantIds);
|
const participants = new Set(participantIds);
|
||||||
const records = summary.records.filter((record) =>
|
return summary.records.some(
|
||||||
participants.has(record.id),
|
(record) =>
|
||||||
);
|
participants.has(record.id) &&
|
||||||
// A layer waiting for a key did not fail: its row names the key. Only an
|
(record.error || record.unavailable || record.keyRequired),
|
||||||
// actual error makes the whole batch read LOAD FAILED.
|
|
||||||
if (
|
|
||||||
records.some(
|
|
||||||
(record) => !record.keyRequired && (record.error || record.unavailable),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return 'error';
|
? 'error'
|
||||||
return records.some((record) => record.keyRequired) ? 'key' : null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aggregate all manager layers without changing their lifecycle authority. */
|
/** Aggregate all manager layers without changing their lifecycle authority. */
|
||||||
@@ -287,7 +282,7 @@ function terminalFromEvent(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeTerminalOutcome(current, next) {
|
function mergeTerminalOutcome(current, next) {
|
||||||
const severity = { complete: 1, key: 2, cancelled: 3, error: 4 };
|
const severity = { complete: 1, cancelled: 2, error: 3 };
|
||||||
if (!next) return current || null;
|
if (!next) return current || null;
|
||||||
if (!current || severity[next] > severity[current]) return next;
|
if (!current || severity[next] > severity[current]) return next;
|
||||||
return current;
|
return current;
|
||||||
@@ -429,7 +424,6 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
|||||||
const labels = {
|
const labels = {
|
||||||
complete: 'LOAD COMPLETE',
|
complete: 'LOAD COMPLETE',
|
||||||
cancelled: 'LOAD CANCELLED',
|
cancelled: 'LOAD CANCELLED',
|
||||||
key: 'KEY REQUIRED',
|
|
||||||
error: 'LOAD FAILED',
|
error: 'LOAD FAILED',
|
||||||
};
|
};
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -247,11 +247,11 @@ test('terminal loading feedback centers its label without an empty detail slot',
|
|||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
assert.match(
|
assert.match(
|
||||||
css,
|
css,
|
||||||
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='key'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
|
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
css,
|
css,
|
||||||
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='key'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/,
|
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ test('participant stats failure outranks a simultaneous visibility completion',
|
|||||||
name: 'AIS Vessels',
|
name: 'AIS Vessels',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
lifecycleState: 'enabled',
|
lifecycleState: 'enabled',
|
||||||
stats: { loading: false, error: 'Upstream closed' },
|
stats: { loading: false, keyRequired: true },
|
||||||
}]);
|
}]);
|
||||||
state = reduceLoadingFeedback(state, missingKey, 250, {
|
state = reduceLoadingFeedback(state, missingKey, 250, {
|
||||||
type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
|
type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
|
||||||
@@ -355,36 +355,6 @@ test('participant stats failure outranks a simultaneous visibility completion',
|
|||||||
assert.equal(state.terminal, 'error');
|
assert.equal(state.terminal, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a layer waiting for a key ends the batch as KEY REQUIRED, not LOAD FAILED', () => {
|
|
||||||
const quakes = (stats) => ({ id: 'earthquakes', name: 'Earthquakes', ...stats });
|
|
||||||
const fires = (stats) => ({ id: 'local-firms', name: 'Active Fires', ...stats });
|
|
||||||
const enabling = aggregateLayerLoading([
|
|
||||||
quakes({ lifecycleState: 'enabling', stats: { loading: true } }),
|
|
||||||
fires({ lifecycleState: 'enabling', stats: { loading: true } }),
|
|
||||||
]);
|
|
||||||
let state = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0);
|
|
||||||
state = reduceLoadingFeedback(state, enabling, 200);
|
|
||||||
const settled = aggregateLayerLoading([
|
|
||||||
quakes({ enabled: true, stats: { count: 27, lastUpdate: 1 } }),
|
|
||||||
fires({ enabled: true, stats: { status: 'unavailable', keyRequired: true, error: 'KEY REQUIRED' } }),
|
|
||||||
]);
|
|
||||||
state = reduceLoadingFeedback(state, settled, 250);
|
|
||||||
assert.equal(state.terminal, 'key');
|
|
||||||
assert.deepEqual(state.failedEventIds, []);
|
|
||||||
const view = presentLoadingFeedback(state, settled, 260);
|
|
||||||
assert.equal(view.state, 'key');
|
|
||||||
assert.equal(view.label, 'KEY REQUIRED');
|
|
||||||
|
|
||||||
// A real failure beside it still reads as one.
|
|
||||||
const broken = aggregateLayerLoading([
|
|
||||||
quakes({ enabled: true, stats: { error: 'USGS 503' } }),
|
|
||||||
fires({ enabled: true, stats: { status: 'unavailable', keyRequired: true } }),
|
|
||||||
]);
|
|
||||||
let failing = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0);
|
|
||||||
failing = reduceLoadingFeedback(failing, enabling, 200);
|
|
||||||
assert.equal(reduceLoadingFeedback(failing, broken, 250).terminal, 'error');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('retains the worst terminal outcome until every concurrent load drains', () => {
|
test('retains the worst terminal outcome until every concurrent load drains', () => {
|
||||||
const both = aggregateLayerLoading([
|
const both = aggregateLayerLoading([
|
||||||
{ id: 'a', name: 'A', lifecycleState: 'enabling' },
|
{ id: 'a', name: 'A', lifecycleState: 'enabling' },
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const STATUS_LABEL_KEYS = Object.freeze({
|
|||||||
'LOAD COMPLETE': msg('LOAD COMPLETE'),
|
'LOAD COMPLETE': msg('LOAD COMPLETE'),
|
||||||
'LOAD CANCELLED': msg('LOAD CANCELLED'),
|
'LOAD CANCELLED': msg('LOAD CANCELLED'),
|
||||||
'LOAD FAILED': msg('LOAD FAILED'),
|
'LOAD FAILED': msg('LOAD FAILED'),
|
||||||
'KEY REQUIRED': msg('KEY REQUIRED'),
|
|
||||||
'LIVE DATA OFF': msg('LIVE DATA OFF'),
|
'LIVE DATA OFF': msg('LIVE DATA OFF'),
|
||||||
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
|
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
|
||||||
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
|
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ html:not([lang='en']) #global-loading-detail {
|
|||||||
#global-loading-status:is(
|
#global-loading-status:is(
|
||||||
[data-state='complete'],
|
[data-state='complete'],
|
||||||
[data-state='cancelled'],
|
[data-state='cancelled'],
|
||||||
[data-state='key'],
|
|
||||||
[data-state='error']
|
[data-state='error']
|
||||||
) {
|
) {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -140,7 +139,6 @@ html:not([lang='en']) #global-loading-detail {
|
|||||||
#global-loading-status:is(
|
#global-loading-status:is(
|
||||||
[data-state='complete'],
|
[data-state='complete'],
|
||||||
[data-state='cancelled'],
|
[data-state='cancelled'],
|
||||||
[data-state='key'],
|
|
||||||
[data-state='error']
|
[data-state='error']
|
||||||
)
|
)
|
||||||
#global-loading-detail {
|
#global-loading-detail {
|
||||||
@@ -157,13 +155,11 @@ html:not([lang='en']) #global-loading-detail {
|
|||||||
#global-loading-status[data-state='cancelled'] {
|
#global-loading-status[data-state='cancelled'] {
|
||||||
color: #ffd488;
|
color: #ffd488;
|
||||||
}
|
}
|
||||||
#global-loading-status:is([data-state='retry'], [data-state='key']) {
|
#global-loading-status[data-state='retry'] {
|
||||||
border-color: rgba(255, 212, 136, 0.55);
|
border-color: rgba(255, 212, 136, 0.55);
|
||||||
}
|
}
|
||||||
#global-loading-status:is([data-state='retry'], [data-state='key'])
|
#global-loading-status[data-state='retry'] #global-loading-label,
|
||||||
#global-loading-label,
|
#global-loading-status[data-state='retry'] #global-loading-detail {
|
||||||
#global-loading-status:is([data-state='retry'], [data-state='key'])
|
|
||||||
#global-loading-detail {
|
|
||||||
color: #ffd488;
|
color: #ffd488;
|
||||||
}
|
}
|
||||||
#global-loading-status[data-state='cancelled'] #global-loading-label,
|
#global-loading-status[data-state='cancelled'] #global-loading-label,
|
||||||
|
|||||||
Reference in new issue
Block a user