Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the
corporate GitHub at 7905917c. wilm rides the publication lineage and shares no
ancestor with wj, so this is a filtered tree patch rather than a merge:
94 files, and every path the publication export strips (docs/, tools/, mcp/,
CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because
a raw diff would delete each of them from this tree.
What comes across:
* Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link
state, boot type, Defender health, processor/memory/chassis inventory, the
listening TCP set, mapped network drives and Windows Update history. Five
additive migrations, 0012 through 0016, off the 0011 head.
* Mapped drives are collected by a probe running in the interactive user's
session, because they do not exist in the agent's session 0. Two Windows
sources can disagree about one drive and the worse status wins, so a
half-broken share is never rendered as healthy.
* Windows events are reported once each, keyed on the channel's own record id,
instead of being re-sent whenever a report overlapped the last one.
* PC Detail rework: Details cards flow, the hand-written Network card gives way
to a full address history, Storage and disk health merge into one card with
two sections, Operator Reports and Corporate Tools get their own tabs.
* An opt-in restic backup overlay with a third container, restore gated on the
dump's own data revision rather than the code head, and backup/restore history.
* The agent's version is read from the binary's PE version rather than trusted
from the csproj, and the Fleet view gains an Agent Version column.
* The enqueue route now asks caller_holds instead of the owner's permissions,
which closes the scoped-token elevation on run_script, update_app and
system_restart.
Held back deliberately:
* agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it.
The installer that reaches a bay is generated by _install_script() in
plugins/endpoints/api/siteagent.py; the standalone script had stopped being
shipped and was collecting fixes no endpoint ever ran.
* The .gitignore hunk adding docs/*.md. Correct on the publication lineage,
where docs/ becomes the wiki and its absence is how the gates detect a
published checkout. Here it would silently ignore every new doc.
* Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream
those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points
readers at.
* Five zero-byte files committed by an accidental `git add -A`, a run log from
the author's own machine, and docs/notes/, which is working scratch that says
so in its own header and belongs in devnotes/ on this side.
Carried with the import so it lands green: the 17 new routes are described in
docs/api-inventory.json with the spec and project map regenerated, and two new
frontend specs read fixtures through fileURLToPath(URL) which node rejects under
jsdom's own URL global.
Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates
clean.
Scope: product
544 lines
19 KiB
Vue
544 lines
19 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Fleet</h2>
|
|
</div>
|
|
|
|
<!-- Summary strip. Wired to dashboard routes that already existed and
|
|
nothing called; each tile is a filter shortcut, not just a number. -->
|
|
<div v-if="summary" class="fleet-tiles">
|
|
<button class="fleet-tile" :class="{ on: status === '' && !problemsonly }" @click="clearFilters">
|
|
<span class="n">{{ summary.total }}</span><span class="l">Endpoints</span>
|
|
</button>
|
|
<button class="fleet-tile ok" :class="{ on: status === 'reporting' }" @click="setStatus('reporting')">
|
|
<span class="n">{{ summary.reporting }}</span><span class="l">Reporting</span>
|
|
</button>
|
|
<button class="fleet-tile warn" :class="{ on: status === 'quiet' }" @click="setStatus('quiet')">
|
|
<span class="n">{{ summary.quiet }}</span><span class="l">Quiet over {{ summary.quiethours }}h</span>
|
|
</button>
|
|
<button class="fleet-tile bad" :class="{ on: status === 'never' }" @click="setStatus('never')">
|
|
<span class="n">{{ summary.never }}</span><span class="l">Never reported</span>
|
|
</button>
|
|
<!-- Crash, shutdown and corruption events in the stability window. The
|
|
route existed and nothing called it. -->
|
|
<button class="fleet-tile bad" :class="{ on: problemsonly }" @click="toggleProblems">
|
|
<span class="n">{{ criticalcount }}</span><span class="l">With critical events</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search hostname, asset or machine number..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<select v-model="status" class="form-control fleet-filter" @change="applyFilters">
|
|
<option value="">All statuses</option>
|
|
<option value="reporting">Reporting</option>
|
|
<option value="quiet">Quiet</option>
|
|
<option value="never">Never reported</option>
|
|
</select>
|
|
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
|
|
<option value="">All PC types</option>
|
|
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
|
|
{{ type.computertype }}
|
|
</option>
|
|
</select>
|
|
<select v-model="locationid" class="form-control fleet-filter" @change="applyFilters">
|
|
<option value="">All locations</option>
|
|
<option v-for="place in locations" :key="place.locationid" :value="place.locationid">
|
|
{{ place.locationname }}
|
|
</option>
|
|
</select>
|
|
<select v-model="sort" class="form-control fleet-filter" @change="applyFilters">
|
|
<option value="status">Sort: worst first</option>
|
|
<option value="hostname">Sort: hostname</option>
|
|
<option value="lastreported">Sort: last reported</option>
|
|
<option value="uptime">Sort: uptime</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Actions live here rather than per row. A tenth column of buttons
|
|
pushed itself off the edge of a nowrap table, and selecting one row
|
|
costs a single click while giving one command path instead of two. -->
|
|
<div v-if="cancommand && selected.length" class="bulk-bar">
|
|
<span class="bulk-count">{{ selected.length }} selected</span>
|
|
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="collectSelected">Collect now</button>
|
|
<button v-if="canrestart" class="btn btn-warning btn-sm" :disabled="busy" @click="restartSelected">Restart</button>
|
|
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="selected = []">Clear</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<div v-else-if="loadError" class="load-error">
|
|
{{ loadError }}
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th v-if="cancommand" class="select-cell">
|
|
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
|
|
</th>
|
|
<th>Status</th>
|
|
<th>Hostname</th>
|
|
<th>Machine</th>
|
|
<th>PC Type</th>
|
|
<th>Health</th>
|
|
<th>Agent Version</th>
|
|
<th>Logged-in User</th>
|
|
<th>Uptime</th>
|
|
<th>Last Reported</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="item in endpoints"
|
|
:key="item.assetid"
|
|
class="clickable-row"
|
|
:class="{ disabled: !item.computerid }"
|
|
@click="openPc(item)"
|
|
>
|
|
<td v-if="cancommand" class="select-cell" @click.stop>
|
|
<input type="checkbox" :value="item.assetid" v-model="selected" />
|
|
</td>
|
|
<td>
|
|
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
|
|
</td>
|
|
<td>{{ item.hostname || '-' }}</td>
|
|
<td>
|
|
<!-- What stops if this PC is dead. On a shop floor that is
|
|
the first thing anyone needs to know. -->
|
|
<span v-if="!item.machines || !item.machines.length">-</span>
|
|
<span v-else>{{ item.machines.map(machineLabel).join(', ') }}</span>
|
|
</td>
|
|
<td>{{ item.computertype || '-' }}</td>
|
|
<td>
|
|
<span v-if="item.health && item.health.label"
|
|
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>{{ item.agentversion || '-' }}</td>
|
|
<td>{{ item.loggedinuser || '-' }}</td>
|
|
<td>{{ item.uptimedisplay || '-' }}</td>
|
|
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
|
</tr>
|
|
<tr v-if="endpoints.length === 0">
|
|
<td :colspan="cancommand ? 10 : 9" style="text-align: center; color: var(--text-light);">
|
|
No endpoints found
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { endpointsApi, computersApi, locationsApi } from '@/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
|
|
const router = useRouter()
|
|
const endpoints = ref([])
|
|
const loading = ref(true)
|
|
// A failed request used to leave endpoints as [] and render "No endpoints
|
|
// found", which is the same thing a healthy empty fleet shows. A trailing-slash
|
|
// redirect broke this list in production while the agent reported correctly all
|
|
// along, and the page said only that there was nothing to see. An error must
|
|
// look like an error.
|
|
const loadError = ref('')
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadEndpoints })
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
const status = ref('')
|
|
// Worst first by default. Sorting by last-reported floats the healthiest
|
|
// endpoints to the top and buries the ones somebody needs to act on.
|
|
const sort = ref('status')
|
|
const summary = ref(null)
|
|
const criticalcount = ref(0)
|
|
const computertypeid = ref('')
|
|
const locationid = ref('')
|
|
const problemsonly = ref(false)
|
|
const computertypes = ref([])
|
|
const locations = ref([])
|
|
const selected = ref([])
|
|
const busy = ref(false)
|
|
|
|
const authStore = useAuthStore()
|
|
const toast = useToast()
|
|
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
|
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
|
|
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
|
|
|
const allselected = computed(() =>
|
|
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
|
|
|
|
function toggleAll(event) {
|
|
selected.value = event.target.checked ? endpoints.value.map(row => row.assetid) : []
|
|
}
|
|
|
|
// What a restart actually costs, in the words that matter on a shop floor. A
|
|
// dialog listing eight hostnames tells an operator nothing about what they are
|
|
// about to halt; the machines those PCs serve is the real consequence.
|
|
function machinesFor(rows) {
|
|
const names = []
|
|
rows.forEach(row => (row.machines || []).forEach(machine => {
|
|
const label = machineLabel(machine)
|
|
if (label && !names.includes(label)) names.push(label)
|
|
}))
|
|
return names
|
|
}
|
|
|
|
function rowsFor(assetids) {
|
|
return endpoints.value.filter(row => assetids.includes(row.assetid))
|
|
}
|
|
|
|
async function collectSelected() {
|
|
await runCommands(rowsFor(selected.value), 'collect_now', 'Collect now')
|
|
}
|
|
|
|
async function restartSelected() {
|
|
const rows = rowsFor(selected.value)
|
|
if (!confirmRestart(rows)) return
|
|
await runCommands(rows, 'system_restart', 'Restart')
|
|
}
|
|
|
|
function confirmRestart(rows) {
|
|
const machines = machinesFor(rows)
|
|
const hosts = rows.map(row => row.hostname).join(', ')
|
|
const consequence = machines.length
|
|
? `\n\nThis STOPS the machines these PCs serve:\n ${machines.join('\n ')}`
|
|
: '\n\nNo machine is recorded against these PCs.'
|
|
return confirm(
|
|
`Restart ${rows.length} computer(s)?\n\n${hosts}${consequence}\n\n`
|
|
+ 'They reboot immediately with no grace period. Unsaved work is lost.')
|
|
}
|
|
|
|
async function runCommands(rows, action, label) {
|
|
if (!rows.length) return
|
|
busy.value = true
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
rows.map(row => endpointsApi.enqueueCommand(row.assetid, action)))
|
|
|
|
const queued = results.filter(r => r.status === 'fulfilled').length
|
|
// 409 means a command is ALREADY in flight for that endpoint - the queue
|
|
// permits one at a time. That is a skip, not a failure, and saying so
|
|
// stops it reading as a broken button.
|
|
const skipped = results.filter(
|
|
r => r.status === 'rejected' && r.reason?.response?.status === 409).length
|
|
const failed = results.length - queued - skipped
|
|
|
|
if (queued) toast.success(`${label} queued for ${queued} endpoint(s)`)
|
|
if (skipped) toast.info(`${skipped} skipped - a command is already in flight`)
|
|
if (failed) {
|
|
const first = results.find(
|
|
r => r.status === 'rejected' && r.reason?.response?.status !== 409)
|
|
toast.error(apiError(first?.reason, `${label} failed on ${failed} endpoint(s)`))
|
|
}
|
|
selected.value = []
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadEndpoints()
|
|
loadSummary()
|
|
loadFilterOptions()
|
|
})
|
|
|
|
async function loadFilterOptions() {
|
|
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
|
|
// location list would silently lose its tail in a dropdown.
|
|
try {
|
|
computertypes.value = await computersApi.types.listAll()
|
|
} catch (error) {
|
|
computertypes.value = []
|
|
}
|
|
try {
|
|
locations.value = await locationsApi.listAll()
|
|
} catch (error) {
|
|
locations.value = []
|
|
}
|
|
}
|
|
|
|
async function loadSummary() {
|
|
try {
|
|
const response = await endpointsApi.dashboardSummary()
|
|
summary.value = response.data.data || null
|
|
const stability = await endpointsApi.dashboardStability()
|
|
criticalcount.value = (stability.data.data || []).length
|
|
} catch (error) {
|
|
// The strip is a convenience, not the page. If it fails the table still
|
|
// works, so say nothing rather than blocking on it.
|
|
summary.value = null
|
|
}
|
|
}
|
|
|
|
async function loadEndpoints() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value
|
|
}
|
|
if (search.value) params.search = search.value
|
|
if (status.value) params.status = status.value
|
|
if (sort.value) params.sort = sort.value
|
|
if (computertypeid.value) params.computertypeid = computertypeid.value
|
|
if (locationid.value) params.locationid = locationid.value
|
|
|
|
loadError.value = ''
|
|
const response = await endpointsApi.list(params)
|
|
const rows = response.data.data || []
|
|
endpoints.value = problemsonly.value
|
|
? rows.filter(row => row.health && row.health.level !== 'ok')
|
|
: rows
|
|
// Selections do not survive a reload: the rows they referred to may not be
|
|
// on this page any more, and acting on an invisible selection is how bulk
|
|
// tools surprise people.
|
|
selected.value = []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading endpoints:', error)
|
|
endpoints.value = []
|
|
// Name the status. 403 and 404 are the two that get misread as "no
|
|
// endpoints": the first means the role lacks endpoints.view, the second
|
|
// that the request never reached the route at all.
|
|
const status = error.response?.status
|
|
loadError.value = status
|
|
? `Could not load endpoints (HTTP ${status}). This is a failed request, not an empty fleet.`
|
|
: 'Could not reach the server to load endpoints.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Interoperate with PC Details instead of a bespoke detail page: a row only
|
|
// navigates when it resolved to a computerid.
|
|
function openPc(item) {
|
|
if (item.computerid) {
|
|
// Tag the origin so PC Details and the edit form return here (Fleet)
|
|
// instead of the PCs list. Absent for the normal PCs-list entry path.
|
|
router.push(`/pcs/${item.computerid}?from=fleet`)
|
|
}
|
|
}
|
|
|
|
function statusLabel(value) {
|
|
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
|
}
|
|
|
|
// Relative beats absolute here: "4m ago" answers "is this box alive" at a
|
|
// glance, where a timestamp makes the reader do the subtraction.
|
|
function formatRelative(value) {
|
|
if (!value) return 'never'
|
|
const then = new Date(value)
|
|
if (isNaN(then)) return '-'
|
|
const mins = Math.floor((Date.now() - then.getTime()) / 60000)
|
|
if (mins < 1) return 'just now'
|
|
if (mins < 60) return `${mins} m ago`
|
|
const hours = Math.floor(mins / 60)
|
|
if (hours < 24) return `${hours} h ago`
|
|
return `${Math.floor(hours / 24)} d ago`
|
|
}
|
|
|
|
function setStatus(value) {
|
|
status.value = value
|
|
problemsonly.value = false
|
|
applyFilters()
|
|
}
|
|
|
|
function clearFilters() {
|
|
status.value = ''
|
|
problemsonly.value = false
|
|
applyFilters()
|
|
}
|
|
|
|
// Client-side, unlike the other filters: "has a problem" is the health rollup,
|
|
// which is assembled per row after pagination rather than in the query. Sorting
|
|
// already puts trouble first, so this narrows the page rather than the fleet -
|
|
// worth knowing before someone reads the count as fleet-wide.
|
|
function toggleProblems() {
|
|
problemsonly.value = !problemsonly.value
|
|
status.value = ''
|
|
applyFilters()
|
|
}
|
|
|
|
function machineLabel(machine) {
|
|
if (machine.name && machine.assetnumber && machine.name !== machine.assetnumber) {
|
|
return `${machine.assetnumber} ${machine.name}`
|
|
}
|
|
return machine.assetnumber || machine.name || ''
|
|
}
|
|
|
|
function applyFilters() {
|
|
setPage(1)
|
|
loadEndpoints()
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadEndpoints()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadEndpoints()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadEndpoints()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.clickable-row.disabled {
|
|
cursor: default;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.load-error {
|
|
padding: 1.5rem;
|
|
text-align: center;
|
|
color: var(--danger);
|
|
}
|
|
|
|
.fleet-tiles {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.fleet-tile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
/* Matches the global .card radius. The tiles were 6px, which left Fleet
|
|
slightly rounder than every other page. */
|
|
border-radius: 0.25rem;
|
|
padding: 0.8rem 0.95rem;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
/* A button does NOT inherit color the way it inherits font, so without this
|
|
the tile renders in the browser default black - invisible on the dark
|
|
theme's card. The coloured tiles set their own color and hid the bug; the
|
|
plain total did not. */
|
|
color: var(--text);
|
|
text-align: left;
|
|
}
|
|
|
|
.fleet-tile:hover,
|
|
.fleet-tile.on { border-color: var(--primary); }
|
|
|
|
.fleet-tile .n {
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.fleet-tile .l {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.fleet-tile.ok .n { color: var(--success-dark); }
|
|
.fleet-tile.warn .n { color: var(--warning-dark); }
|
|
.fleet-tile.bad .n { color: var(--danger); }
|
|
|
|
/* Dark mode redefines --success and --warning to bright variants, but NOT the
|
|
-dark ones, which stay tuned for a white background. Left alone they render
|
|
dark green and dark amber on the dark card. Scoped to the stamped attribute
|
|
(the theme store always sets it) rather than prefers-color-scheme, which
|
|
would leak dark styling into light mode on a dark-OS machine. */
|
|
[data-theme="dark"] .fleet-tile.ok .n { color: var(--success); }
|
|
[data-theme="dark"] .fleet-tile.warn .n { color: var(--warning); }
|
|
|
|
.health-pill.ok { color: var(--success-dark); }
|
|
.health-pill.warning { color: var(--warning-dark); }
|
|
.health-pill.critical { color: var(--danger); }
|
|
|
|
[data-theme="dark"] .health-pill.ok { color: var(--success); }
|
|
[data-theme="dark"] .health-pill.warning { color: var(--warning); }
|
|
|
|
.fleet-filter { max-width: 200px; }
|
|
|
|
.bulk-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.9rem;
|
|
margin-bottom: 0.75rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--primary);
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
|
|
|
.select-cell { width: 1%; }
|
|
|
|
|
|
|
|
.status-dot {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
margin-right: 0.45rem;
|
|
vertical-align: 1px;
|
|
background: var(--text-light);
|
|
}
|
|
|
|
.status-dot.reporting { background: var(--success); }
|
|
.status-dot.quiet { background: var(--warning); }
|
|
.status-dot.never { background: var(--text-light); opacity: 0.5; }
|
|
|
|
/* Health is a phrase, not a score: "eDNC stopped" tells an operator what to
|
|
do, where a percentage does not. */
|
|
.health-pill {
|
|
display: inline-block;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
padding: 0.1rem 0.5rem;
|
|
border-radius: 10px;
|
|
background: var(--bg);
|
|
color: var(--text-light);
|
|
}
|
|
|
|
</style>
|