PC Detail: flow Details cards, drop the IP Configuration card
Details tab card layout ----------------------- The Details tab sat on .content-grid, a two-column CSS grid. Grid packs items into implicit rows, so every row is as tall as its tallest card and align-items:start leaves the shorter one with dead space beneath it: Warranty is short, Endpoint Status is tall, and Policy had to wait for the taller of the two rather than sliding up. That read as a staggered layout. Switched that one tab to multicol via a .details-flow modifier, which has no rows at all - each card starts where the previous one ended, so the holes close while cards still size to their content. Multicol is deliberately NOT applied to .content-grid globally. The note by that rule in style.css records why it was abandoned there: a column-span:all child (.pap-span, used by table panels) overlaps the shorter column when the two are unequal. That cannot happen on this tab - every panel in the details group is a keyvalue or list render, and spansFullWidth() only returns true for table (or a tabs panel holding one), so nothing in the subtree spans. Monitors and Service/Process DO carry table panels and stay on grid. The modifier is scoped to PCDetail, so the nine other detail views on .content-grid are untouched. Chosen over JS masonry on purpose. Masonry packs perfectly but has to measure every card and re-measure on resize, on panel collapse and on every async panel load - and these panels fetch after paint, so it would mean continuous forced reflow on a page that already polls. This is pure CSS: no measurement, no observer, no runtime cost. The tradeoff accepted is that column bottoms can end at slightly different heights. Cards carry break-inside: avoid so none fragments across the column break, and the 25px spacing returns as a margin because multicol has no gap between stacked items the way grid does. IP Configuration card --------------------- Removed: the data has no purpose on the page anymore. Taken out at the root rather than hidden - the panel declaration, the /asset/<id>/ipconfig route and the _IPCONFIG_COLUMNS constant all go, since a route with no consumer is dead surface that still answers requests. test_the_two_cards_do_not_duplicate_columns_beyond_the_address existed only to police the history/config column split; with one card gone it guards nothing, so it and its import go with it. The panel-count assertion in test_endpoints_asset_panels_are_computer_scoped moves 15 -> 14, which is the test doing its job and catching the removal. IP History is unchanged and still carries the address list. Not addressed here: three tzset() tests in test_endpoints_plugin.py fail on Windows because time.tzset() is Unix-only. Verified pre-existing against unmodified code; they pass in the Linux container.
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
long for a narrow column and it wrapped into a tall ragged
|
||||
stack. Full width in the normal flow costs one short row. -->
|
||||
<div class="hero-primary">
|
||||
<div class="hero-headline">
|
||||
<div class="hero-identity">
|
||||
<div class="hero-title">
|
||||
<h1>{{ computer.assetnumber }}</h1>
|
||||
@@ -70,6 +71,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liveness and the endpoint actions, pulled out of the triage
|
||||
band into the header corner. They are the two things looked at
|
||||
first on a PC that is misbehaving, and at the bottom of the
|
||||
hero they sat below the fold on a laptop. The band keeps the
|
||||
reference facts (serves machine, remote access, location). -->
|
||||
<div class="hero-endpoint">
|
||||
<div v-if="liveness" class="hero-status">
|
||||
<span class="hero-detail-label hero-status-label">Status</span>
|
||||
<span class="triage-chip" :class="liveness.level">
|
||||
<span class="triage-dot"></span>{{ liveness.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="canCommand" class="hero-endpoint-actions">
|
||||
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
|
||||
:disabled="restarting || refreshing">
|
||||
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud identity. Laid out as a horizontal row in the same
|
||||
label-over-value language as hero-details, not as a narrow
|
||||
right rail: a 36-character GUID cannot wrap sanely into a
|
||||
@@ -96,12 +118,13 @@
|
||||
</span>
|
||||
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
|
||||
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
title="Copy Device ID"
|
||||
aria-label="Copy Device ID"
|
||||
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
|
||||
>Copy</button>
|
||||
><Copy :size="14" aria-hidden="true" /></button>
|
||||
</span>
|
||||
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
|
||||
</div>
|
||||
@@ -115,15 +138,24 @@
|
||||
rel="noopener noreferrer"
|
||||
class="portal-link"
|
||||
>Entra</a>
|
||||
<a
|
||||
v-if="lapsDeviceLink"
|
||||
:href="lapsDeviceLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="portal-link"
|
||||
title="Local administrator password (LAPS)"
|
||||
>Azure LAPS</a>
|
||||
</span>
|
||||
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
|
||||
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
title="Copy Object ID"
|
||||
aria-label="Copy Object ID"
|
||||
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
|
||||
>Copy</button>
|
||||
><Copy :size="14" aria-hidden="true" /></button>
|
||||
</span>
|
||||
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
|
||||
</div>
|
||||
@@ -138,17 +170,16 @@
|
||||
it. "Serves machine" used to sit in the relationships card near
|
||||
the bottom of the page, which is the wrong place for the single
|
||||
most useful fact about a shop-floor PC. -->
|
||||
<!-- Liveness and Refresh Status moved to .hero-endpoint in the header
|
||||
corner. Restart stays here: it is destructive and takes the
|
||||
machine down with it, so it belongs beside the "serves machine"
|
||||
fact that says what stops, not next to a read-only refresh. -->
|
||||
<AssetTriageBand
|
||||
:assetid="computer.assetid"
|
||||
:liveness="liveness"
|
||||
:accessmethods="computer.accessmethods || []"
|
||||
>
|
||||
<template #actions>
|
||||
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary btn-sm"
|
||||
:disabled="restarting || refreshing">
|
||||
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
|
||||
</button>
|
||||
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning btn-sm"
|
||||
<template #actions v-if="canRestart">
|
||||
<button @click="confirmRestart" class="btn btn-warning btn-sm"
|
||||
:disabled="restarting">Restart Computer</button>
|
||||
</template>
|
||||
</AssetTriageBand>
|
||||
@@ -181,7 +212,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activegroup === 'details'" class="content-grid">
|
||||
<div v-show="activegroup === 'details'" class="content-grid details-flow">
|
||||
<PluginAssetPanels
|
||||
ref="assetPanels"
|
||||
:assetid="computer.assetid"
|
||||
@@ -271,31 +302,13 @@
|
||||
now: it is what a responder clicks first, and rendering it twice
|
||||
made the page longer without telling anyone anything. -->
|
||||
|
||||
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
|
||||
collector), which is a different source from the endpoints
|
||||
agent's live telemetry panel. Shown only when that record holds
|
||||
something: on a PC with an agent these fields sit empty while
|
||||
the endpoint panel is fully populated, so an always-on card
|
||||
reported "Last Reported: Never" next to live telemetry saying
|
||||
otherwise. Two sources disagreeing in public is worse than one
|
||||
source missing. -->
|
||||
<div v-if="hasRecordCheckin" class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The record-checkin Status card lived here (Logged In User, Last
|
||||
Reported, Last Boot), sourced from the asset-report collector
|
||||
rather than the agent. It restated what the Endpoint status panel
|
||||
already shows from live telemetry, and the two sources phrase the
|
||||
same facts differently, so the page appeared to disagree with
|
||||
itself. The panel is the better source: it is populated by the
|
||||
agent and carries uptime, versions and drift alongside. -->
|
||||
|
||||
<!-- The Entra Device Info card lived here. Its links moved to the
|
||||
header's right-hand block, which was dead space at every width,
|
||||
@@ -425,7 +438,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
@@ -438,6 +451,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { Copy } from 'lucide-vue-next'
|
||||
import { getEntraDeviceUrls } from '@/utils/siteSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
@@ -465,13 +479,6 @@ const computer = ref(null)
|
||||
// shown, which is better than rendering "unknown" and implying a fault.
|
||||
const liveness = ref(null)
|
||||
|
||||
// The computer record's own check-in fields, as opposed to live agent
|
||||
// telemetry. Empty on an agent-managed PC, populated where the asset-report
|
||||
// script runs instead.
|
||||
const hasRecordCheckin = computed(() => {
|
||||
const record = computer.value?.computer || {}
|
||||
return Boolean(record.loggedinuser || record.lastreporteddate || record.lastboottime)
|
||||
})
|
||||
const installedApps = ref([])
|
||||
const assetPanels = ref(null)
|
||||
|
||||
@@ -485,12 +492,46 @@ const activegroup = ref('details')
|
||||
// blade paths, so guessing one would send people to a dead page.
|
||||
const entraUrlTemplate = ref('')
|
||||
const intuneUrlTemplate = ref('')
|
||||
const lapsUrlTemplate = ref('')
|
||||
// The templates arrive from an async settings fetch. Until that settles the
|
||||
// block stays hidden: rendering the ids first and popping the Intune/Entra
|
||||
// buttons in a moment later reads as the buttons being missing, which is
|
||||
// exactly what it looked like before a manual refresh warmed the cache.
|
||||
const portalTemplatesLoaded = ref(false)
|
||||
|
||||
// The settings util negative-caches a failed load for 30s, so an immediate
|
||||
// retry would just re-read the same empty result. Wait past that window.
|
||||
const PORTAL_RETRY_MS = 31000
|
||||
let portalRetryTimer = null
|
||||
|
||||
async function loadPortalTemplates({ allowRetry = true } = {}) {
|
||||
try {
|
||||
const { entraUrl, intuneUrl, lapsUrl, loaded } = await getEntraDeviceUrls()
|
||||
if (!loaded && allowRetry) {
|
||||
// Settings did not load. Leave the block hidden rather than paint a
|
||||
// wrong "not configured" state, and try again once.
|
||||
portalRetryTimer = setTimeout(() => {
|
||||
portalRetryTimer = null
|
||||
loadPortalTemplates({ allowRetry: false })
|
||||
}, PORTAL_RETRY_MS)
|
||||
return
|
||||
}
|
||||
entraUrlTemplate.value = entraUrl
|
||||
intuneUrlTemplate.value = intuneUrl
|
||||
lapsUrlTemplate.value = lapsUrl
|
||||
} catch (err) {
|
||||
// Ignore: templates stay empty and the block falls back to whatever else
|
||||
// it has rather than hiding forever.
|
||||
} finally {
|
||||
// Only settle once the answer is trustworthy, or after the retry is spent.
|
||||
if (!portalRetryTimer) portalTemplatesLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (portalRetryTimer) clearTimeout(portalRetryTimer)
|
||||
})
|
||||
|
||||
function fillTemplate(template, token, value) {
|
||||
if (!template || !value) return null
|
||||
return template.split(token).join(encodeURIComponent(value))
|
||||
@@ -499,6 +540,9 @@ const entraDeviceLink = computed(() => fillTemplate(
|
||||
entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
|
||||
const intuneDeviceLink = computed(() => fillTemplate(
|
||||
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
|
||||
// LAPS is a blade on the same device as Entra, so it keys off the OBJECT id.
|
||||
const lapsDeviceLink = computed(() => fillTemplate(
|
||||
lapsUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
|
||||
|
||||
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
|
||||
// and rejects when the tab is not focused. Say copy failed and leave the id
|
||||
@@ -521,7 +565,7 @@ async function copyId(value, label) {
|
||||
// get. Where templates exist, a PC missing an id shows which one is missing -
|
||||
// that is the actionable case the empty state was requested for.
|
||||
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||
entraUrlTemplate.value || intuneUrlTemplate.value
|
||||
entraUrlTemplate.value || intuneUrlTemplate.value || lapsUrlTemplate.value
|
||||
|| computer.value?.computer?.azuredevicecategory))
|
||||
|
||||
// Only offer a tab that has something in it. An empty tab is worse than an
|
||||
@@ -612,14 +656,12 @@ function relativeAge(value) {
|
||||
onMounted(async () => {
|
||||
// Non-critical: a site that has not configured these just gets no link, so a
|
||||
// failure here must not stop the page loading.
|
||||
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
|
||||
entraUrlTemplate.value = entraUrl
|
||||
intuneUrlTemplate.value = intuneUrl
|
||||
}).catch(() => {}).finally(() => {
|
||||
// Settled either way. On failure the templates stay empty and the block
|
||||
// falls back to whatever else it has, rather than hiding forever.
|
||||
portalTemplatesLoaded.value = true
|
||||
})
|
||||
//
|
||||
// A failed settings fetch is negative-cached for 30s and resolves EMPTY, which
|
||||
// is indistinguishable from "not configured" by value alone - the block would
|
||||
// hide itself and only appear on a later visit that happened to land after the
|
||||
// window. Retry once past that window instead of showing a wrong empty state.
|
||||
loadPortalTemplates()
|
||||
|
||||
try {
|
||||
const response = await computersApi.get(route.params.id)
|
||||
@@ -675,6 +717,12 @@ async function refreshStatus() {
|
||||
? await waitForFreshTelemetry(baseline)
|
||||
: false
|
||||
await assetPanels.value?.refresh()
|
||||
// The liveness chip reads its own endpoint, so refreshing the panels alone
|
||||
// leaves it showing the age it was rendered with at page load - the button
|
||||
// looked like it had done nothing. Re-read it whatever the outcome: a
|
||||
// timeout or failure still moves the chip toward quiet, which is the
|
||||
// honest reading.
|
||||
await loadLiveness(computer.value?.assetid)
|
||||
if (outcome === 'timeout') {
|
||||
toast.info('Refresh queued; the endpoint agent may be offline or busy')
|
||||
} else if (outcome === 'failed') {
|
||||
@@ -815,6 +863,19 @@ async function confirmRetire() {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* Identity on the left, liveness and endpoint actions pinned to the right.
|
||||
.hero-primary is a COLUMN (identity, then the portals row beneath), so the
|
||||
corner block needs its own row here rather than margin-left:auto against
|
||||
the column, which would just stack it under the identity. */
|
||||
.hero-headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero-headline > .hero-identity { flex: 1 1 auto; }
|
||||
@media (max-width: 768px) {
|
||||
.hero-headline { flex-wrap: wrap; }
|
||||
}
|
||||
.hero-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -871,16 +932,146 @@ async function confirmRetire() {
|
||||
}
|
||||
.copy-btn {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.2rem;
|
||||
line-height: 0;
|
||||
color: var(--text-light);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Liveness + endpoint actions in the header corner. Pinned to the top right of
|
||||
.hero-primary, which is the flex row holding the identity block. */
|
||||
.hero-endpoint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.hero-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
/* A touch larger than the shared hero label (10px in style.css). Scoped to
|
||||
this one rather than bumping .hero-detail-label, which PC Type / OS /
|
||||
Location also use and which is sized to sit quietly under the identity. */
|
||||
.hero-status-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
.hero-endpoint-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
|
||||
this copy of the markup. Kept in step with that component deliberately. */
|
||||
.triage-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.triage-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.4rem;
|
||||
background: var(--text-light);
|
||||
}
|
||||
.triage-chip.ok .triage-dot { background: var(--success); }
|
||||
.triage-chip.warning .triage-dot { background: var(--warning); }
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
.triage-chip.critical { color: var(--danger); }
|
||||
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
|
||||
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
|
||||
|
||||
/* The Details tab flows its cards instead of sitting on grid rows.
|
||||
Grid pairs items into implicit rows, so a row is as tall as its tallest
|
||||
card and align-items:start leaves the shorter one with dead space beneath
|
||||
it - Warranty is short, Endpoint Status is tall, and Policy has to wait for
|
||||
the taller of the two instead of sliding up. Multicol has no rows, so each
|
||||
card starts where the previous one ended and the holes close.
|
||||
|
||||
Multicol was rejected for .content-grid globally because a column-span:all
|
||||
child (.pap-span, used by table panels) overlaps the shorter column - see
|
||||
the note by .content-grid in style.css. That cannot happen here: this tab's
|
||||
panels are all keyvalue/list renders and spansFullWidth() only returns true
|
||||
for table (or a tabs panel holding one), so nothing in this subtree spans.
|
||||
Monitors and Service/Process DO carry table panels and stay on grid.
|
||||
|
||||
Pure CSS, so there is no measuring, no observer and no reflow cost - the
|
||||
JS-masonry option was declined for exactly that reason.
|
||||
|
||||
The tradeoff multicol brings is reading order: cards run down the left
|
||||
column then down the right, so DOM order still matches visual order here
|
||||
(it is the same top-to-bottom-per-column flow), and column-count keeps
|
||||
focus order intact because no item is visually reordered across columns. */
|
||||
.content-grid.details-flow {
|
||||
display: block;
|
||||
column-count: 2;
|
||||
column-gap: 25px;
|
||||
}
|
||||
|
||||
/* display:contents on .content-column would leave the wrappers invisible to
|
||||
multicol as well, but the cards inside them need the break rule, so target
|
||||
the cards themselves wherever they sit. :deep() because the plugin panels
|
||||
are rendered by PluginAssetPanels and carry that component's scope id. */
|
||||
.content-grid.details-flow :deep(.section-card) {
|
||||
break-inside: avoid;
|
||||
/* A card split across the column break would show its header in one column
|
||||
and its rows in the other. */
|
||||
-webkit-column-break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
/* Multicol has no gap between stacked items the way grid does, so the
|
||||
spacing that grid's gap supplied has to come back as a margin. */
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
/* One column below the hero breakpoint, matching .content-grid's own
|
||||
single-column collapse so the two tabs behave the same on a phone. */
|
||||
@media (max-width: 768px) {
|
||||
.content-grid.details-flow {
|
||||
column-count: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Restart is the heaviest action on the page and sat visually equal to the
|
||||
reference chips beside it. ~25% up from .btn-sm (10px / 6px 15px) so the
|
||||
weight matches the consequence. Scoped with :deep() because the button is
|
||||
passed into AssetTriageBand's slot, so the band's scope id lands on it. */
|
||||
:deep(.triage-actions .btn-warning) {
|
||||
font-size: 12.5px;
|
||||
padding: 7.5px 19px;
|
||||
}
|
||||
|
||||
/* Below the hero's two-column breakpoint the corner block would strand the
|
||||
buttons against the right edge of a narrow screen. */
|
||||
@media (max-width: 768px) {
|
||||
.hero-endpoint {
|
||||
margin-left: 0;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.hero-status { align-items: flex-start; }
|
||||
.hero-endpoint-actions { justify-content: flex-start; }
|
||||
}
|
||||
.copy-btn:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
|
||||
Reference in New Issue
Block a user