feat: page mises à jour avec liste des paquets par cible
- Backend: GET /api/updates/targets (pct list via SSH) - Backend: GET /api/updates/packages?target= (apt list --upgradable) - Frontend: grille de cards par cible (host + chaque LXC) - Bouton Check/Update par card, liste paquets dépliable (version actuelle → nouvelle) - Boutons globaux "Tout vérifier" et "Tout mettre à jour" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c6028b6951
commit
82e3b850d0
5 changed files with 493 additions and 102 deletions
|
|
@ -78,7 +78,7 @@
|
|||
"disconnected": "Disconnected"
|
||||
},
|
||||
"updates": {
|
||||
"desc": "Run apt updates on the host or LXC containers.",
|
||||
"desc": "Check and run apt updates on the host or LXC containers.",
|
||||
"selectTarget": "Select target",
|
||||
"targetHost": "Proxmox Host",
|
||||
"targetAll": "All LXC",
|
||||
|
|
@ -87,6 +87,16 @@
|
|||
"output": "Output",
|
||||
"history": "History",
|
||||
"noHistory": "No updates performed",
|
||||
"checkUpdates": "Check",
|
||||
"checkAll": "Check all",
|
||||
"updateTarget": "Update",
|
||||
"updateAll": "Update all",
|
||||
"packagesToUpdate": "package(s) to update",
|
||||
"upToDate": "Up to date",
|
||||
"notChecked": "Not checked",
|
||||
"checking": "Checking...",
|
||||
"loadingTargets": "Loading targets...",
|
||||
"stopped": "Stopped",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"success": "Success",
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
"disconnected": "Déconnecté"
|
||||
},
|
||||
"updates": {
|
||||
"desc": "Lancez des mises à jour apt sur le host ou les LXC.",
|
||||
"desc": "Vérifiez et lancez des mises à jour apt sur le host ou les LXC.",
|
||||
"selectTarget": "Sélectionner la cible",
|
||||
"targetHost": "Host Proxmox",
|
||||
"targetAll": "Tous les LXC",
|
||||
|
|
@ -87,6 +87,16 @@
|
|||
"output": "Sortie",
|
||||
"history": "Historique",
|
||||
"noHistory": "Aucune mise à jour effectuée",
|
||||
"checkUpdates": "Vérifier",
|
||||
"checkAll": "Tout vérifier",
|
||||
"updateTarget": "Mettre à jour",
|
||||
"updateAll": "Tout mettre à jour",
|
||||
"packagesToUpdate": "paquet(s) à mettre à jour",
|
||||
"upToDate": "À jour",
|
||||
"notChecked": "Non vérifié",
|
||||
"checking": "Vérification...",
|
||||
"loadingTargets": "Chargement des cibles...",
|
||||
"stopped": "Arrêté",
|
||||
"status": {
|
||||
"running": "En cours",
|
||||
"success": "Succès",
|
||||
|
|
|
|||
|
|
@ -1,47 +1,109 @@
|
|||
<template>
|
||||
<div class="updates-page">
|
||||
<div class="page-header">
|
||||
<h2>{{ t('nav.updates') }}</h2>
|
||||
<p class="text-muted">{{ t('updates.desc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélection de la cible -->
|
||||
<div class="neu-card target-card">
|
||||
<h3>{{ t('updates.selectTarget') }}</h3>
|
||||
<div class="targets">
|
||||
<button
|
||||
v-for="target in targets"
|
||||
:key="target.value"
|
||||
:class="['neu-btn', selectedTarget === target.value ? 'neu-btn--primary' : '']"
|
||||
@click="selectedTarget = target.value"
|
||||
>
|
||||
{{ t(target.label) }}
|
||||
<div>
|
||||
<h2>{{ t('nav.updates') }}</h2>
|
||||
<p class="text-muted">{{ t('updates.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="neu-btn neu-btn--sm" :disabled="checkingAll || loadingTargets" @click="checkAll">
|
||||
<span v-if="checkingAll" class="neu-loading">⟳</span>
|
||||
{{ t('updates.checkAll') }}
|
||||
</button>
|
||||
<button class="neu-btn neu-btn--success" :disabled="anyRunning" @click="updateAllTargets">
|
||||
{{ anyRunning ? t('updates.running') : t('updates.updateAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="neu-btn neu-btn--success"
|
||||
:disabled="!selectedTarget || running"
|
||||
@click="startUpdate"
|
||||
style="margin-top: var(--neu-space-md);"
|
||||
>
|
||||
{{ running ? t('updates.running') : t('updates.start') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Terminal de sortie -->
|
||||
<div v-if="currentJob" class="neu-card output-card">
|
||||
<!-- Loading -->
|
||||
<div v-if="loadingTargets" class="neu-card loading-card">
|
||||
<p class="text-muted">⟳ {{ t('updates.loadingTargets') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Target cards -->
|
||||
<div v-else class="targets-grid">
|
||||
<div
|
||||
v-for="target in targets"
|
||||
:key="target.id"
|
||||
class="target-card neu-card"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="target-header">
|
||||
<div class="target-title">
|
||||
<span class="target-name">{{ target.name }}</span>
|
||||
<span v-if="target.vmid" class="target-vmid">LXC {{ target.vmid }}</span>
|
||||
</div>
|
||||
<span :class="['neu-badge', target.status === 'running' ? 'neu-badge--success' : 'neu-badge--warning']">
|
||||
{{ target.status === 'running' ? t('proxmox.running') : t('updates.stopped') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Package status -->
|
||||
<div class="target-status">
|
||||
<template v-if="target.checking">
|
||||
<span class="text-muted text-xs">⟳ {{ t('updates.checking') }}</span>
|
||||
</template>
|
||||
<template v-else-if="target.error">
|
||||
<span class="neu-badge neu-badge--danger" :title="target.error">⚠ Erreur</span>
|
||||
</template>
|
||||
<template v-else-if="target.packages === null">
|
||||
<span class="not-checked text-muted">{{ t('updates.notChecked') }}</span>
|
||||
</template>
|
||||
<template v-else-if="target.packages.length === 0">
|
||||
<span class="neu-badge neu-badge--success">✓ {{ t('updates.upToDate') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="pkg-count-btn" @click="target.showPackages = !target.showPackages">
|
||||
<span class="neu-badge neu-badge--warning">{{ target.packages.length }} {{ t('updates.packagesToUpdate') }}</span>
|
||||
<span class="pkg-toggle">{{ target.showPackages ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Package list (expandable) -->
|
||||
<div v-if="target.showPackages && target.packages?.length" class="package-list neu-inset">
|
||||
<div v-for="pkg in target.packages" :key="pkg.name" class="package-item">
|
||||
<span class="pkg-name">{{ pkg.name }}</span>
|
||||
<span class="pkg-versions text-muted">{{ pkg.old_version }} → <strong>{{ pkg.version }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="target-actions">
|
||||
<button
|
||||
class="neu-btn neu-btn--sm"
|
||||
:disabled="target.checking || target.status !== 'running'"
|
||||
@click="checkTarget(target)"
|
||||
>
|
||||
{{ t('updates.checkUpdates') }}
|
||||
</button>
|
||||
<button
|
||||
class="neu-btn neu-btn--sm neu-btn--success"
|
||||
:disabled="target.updating || target.status !== 'running'"
|
||||
@click="updateTarget(target)"
|
||||
>
|
||||
<span v-if="target.updating" class="neu-loading">⟳</span>
|
||||
{{ target.updating ? t('updates.running') : t('updates.updateTarget') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output terminal -->
|
||||
<div v-if="activeJob" class="neu-card output-card">
|
||||
<div class="output-header flex items-center justify-between">
|
||||
<h3>{{ t('updates.output') }}</h3>
|
||||
<span :class="['neu-badge', jobStatus === 'success' ? 'neu-badge--success' : jobStatus === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${jobStatus}`) }}
|
||||
<h3>{{ t('updates.output') }} — {{ activeJob.target }}</h3>
|
||||
<span :class="['neu-badge', activeJob.status === 'success' ? 'neu-badge--success' : activeJob.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${activeJob.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="output-terminal neu-inset" ref="terminalEl">
|
||||
<pre class="output-text">{{ outputText }}</pre>
|
||||
<pre class="output-text">{{ activeJob.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historique -->
|
||||
<!-- History -->
|
||||
<div class="neu-card">
|
||||
<h3>{{ t('updates.history') }}</h3>
|
||||
<div v-if="history.length === 0" class="empty-state">
|
||||
|
|
@ -49,7 +111,7 @@
|
|||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-for="entry in history" :key="entry.job_id" class="history-item">
|
||||
<div class="history-meta flex items-center gap-sm">
|
||||
<div class="flex items-center gap-sm">
|
||||
<span :class="['neu-badge', entry.status === 'success' ? 'neu-badge--success' : entry.status === 'error' ? 'neu-badge--danger' : 'neu-badge--warning']">
|
||||
{{ t(`updates.status.${entry.status}`) }}
|
||||
</span>
|
||||
|
|
@ -63,31 +125,182 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const selectedTarget = ref('host')
|
||||
const running = ref(false)
|
||||
const currentJob = ref<string | null>(null)
|
||||
const outputText = ref('')
|
||||
const jobStatus = ref('running')
|
||||
interface Package {
|
||||
name: string
|
||||
version: string
|
||||
old_version: string
|
||||
}
|
||||
|
||||
interface Target {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
vmid?: number
|
||||
packages: Package[] | null
|
||||
checking: boolean
|
||||
updating: boolean
|
||||
showPackages: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface ActiveJob {
|
||||
jobId: string
|
||||
target: string
|
||||
output: string
|
||||
status: 'running' | 'success' | 'error'
|
||||
}
|
||||
|
||||
const targets = ref<Target[]>([])
|
||||
const loadingTargets = ref(true)
|
||||
const activeJob = ref<ActiveJob | null>(null)
|
||||
const history = ref<any[]>([])
|
||||
const terminalEl = ref<HTMLElement | null>(null)
|
||||
|
||||
let wsConnection: WebSocket | null = null
|
||||
|
||||
const targets = [
|
||||
{ value: 'host', label: 'updates.targetHost' },
|
||||
{ value: 'all', label: 'updates.targetAll' },
|
||||
]
|
||||
const checkingAll = computed(() => targets.value.some(t => t.checking))
|
||||
const anyRunning = computed(() => targets.value.some(t => t.updating))
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTargets()
|
||||
await loadHistory()
|
||||
})
|
||||
|
||||
onMounted(loadHistory)
|
||||
onUnmounted(() => wsConnection?.close())
|
||||
|
||||
async function loadTargets() {
|
||||
loadingTargets.value = true
|
||||
try {
|
||||
const res = await fetch('/api/updates/targets', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data: any[] = await res.json() || []
|
||||
targets.value = data.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
status: t.status,
|
||||
vmid: t.vmid,
|
||||
packages: null,
|
||||
checking: false,
|
||||
updating: false,
|
||||
showPackages: false,
|
||||
error: null,
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
loadingTargets.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTarget(target: Target) {
|
||||
target.checking = true
|
||||
target.error = null
|
||||
try {
|
||||
const res = await fetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
target.packages = await res.json() || []
|
||||
} else {
|
||||
target.error = 'Erreur lors de la vérification'
|
||||
target.packages = null
|
||||
}
|
||||
} catch {
|
||||
target.error = 'Erreur réseau'
|
||||
target.packages = null
|
||||
} finally {
|
||||
target.checking = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAll() {
|
||||
// Séquentiel pour ne pas saturer le SSH pool
|
||||
for (const target of targets.value) {
|
||||
if (target.status === 'running') {
|
||||
await checkTarget(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTarget(target: Target) {
|
||||
target.updating = true
|
||||
target.error = null
|
||||
const res = await fetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ target: target.id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
target.updating = false
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
connectJobWS(data.job_id, target.name, () => {
|
||||
target.updating = false
|
||||
checkTarget(target)
|
||||
loadHistory()
|
||||
})
|
||||
}
|
||||
|
||||
async function updateAllTargets() {
|
||||
const res = await fetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ target: 'all' }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
// Marquer tous les LXC running comme "updating"
|
||||
targets.value.forEach(t => { if (t.status === 'running') t.updating = true })
|
||||
connectJobWS(data.job_id, 'all LXC', () => {
|
||||
targets.value.forEach(t => { t.updating = false })
|
||||
loadHistory()
|
||||
checkAll()
|
||||
})
|
||||
}
|
||||
|
||||
function connectJobWS(jobId: string, targetName: string, onDone: () => void) {
|
||||
wsConnection?.close()
|
||||
activeJob.value = {
|
||||
jobId,
|
||||
target: targetName,
|
||||
output: '',
|
||||
status: 'running',
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${jobId}?token=${authStore.accessToken}`)
|
||||
wsConnection.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'update_output' && msg.payload?.chunk && activeJob.value) {
|
||||
activeJob.value.output += msg.payload.chunk
|
||||
await nextTick()
|
||||
if (terminalEl.value) terminalEl.value.scrollTop = terminalEl.value.scrollHeight
|
||||
} else if (msg.type === 'update_done') {
|
||||
if (activeJob.value) activeJob.value.status = 'success'
|
||||
wsConnection?.close()
|
||||
onDone()
|
||||
} else if (msg.type === 'update_error') {
|
||||
if (activeJob.value) activeJob.value.status = 'error'
|
||||
wsConnection?.close()
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const res = await fetch('/api/updates/history', {
|
||||
headers: { Authorization: `Bearer ${authStore.accessToken}` },
|
||||
|
|
@ -95,71 +308,86 @@ async function loadHistory() {
|
|||
if (res.ok) history.value = await res.json() || []
|
||||
}
|
||||
|
||||
async function startUpdate() {
|
||||
running.value = true
|
||||
outputText.value = ''
|
||||
jobStatus.value = 'running'
|
||||
|
||||
const res = await fetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ target: selectedTarget.value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
running.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
currentJob.value = data.job_id
|
||||
|
||||
// Connecter le WebSocket de streaming
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsConnection = new WebSocket(`${proto}//${window.location.host}/ws/updates/${data.job_id}?token=${authStore.accessToken}`)
|
||||
|
||||
wsConnection.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === 'update_output' && msg.payload?.chunk) {
|
||||
outputText.value += msg.payload.chunk
|
||||
await nextTick()
|
||||
if (terminalEl.value) {
|
||||
terminalEl.value.scrollTop = terminalEl.value.scrollHeight
|
||||
}
|
||||
} else if (msg.type === 'update_done') {
|
||||
jobStatus.value = 'success'
|
||||
running.value = false
|
||||
wsConnection?.close()
|
||||
loadHistory()
|
||||
} else if (msg.type === 'update_error') {
|
||||
jobStatus.value = 'error'
|
||||
running.value = false
|
||||
wsConnection?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.updates-page { max-width: 900px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
|
||||
.updates-page { max-width: 1200px; display: flex; flex-direction: column; gap: var(--neu-space-lg); }
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-md); }
|
||||
.page-header h2 { font-size: var(--neu-font-xl); color: var(--neu-text); }
|
||||
.text-muted { color: var(--neu-text-muted); }
|
||||
.text-xs { font-size: var(--neu-font-xs); }
|
||||
|
||||
.target-card h3, .output-card h3 { margin-bottom: var(--neu-space-md); color: var(--neu-text); }
|
||||
.header-actions { display: flex; gap: var(--neu-space-sm); flex-shrink: 0; align-items: flex-start; }
|
||||
|
||||
.targets { display: flex; flex-wrap: wrap; gap: var(--neu-space-sm); }
|
||||
.loading-card { text-align: center; padding: var(--neu-space-xl); }
|
||||
|
||||
.targets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--neu-space-md);
|
||||
}
|
||||
|
||||
.target-card { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
|
||||
|
||||
.target-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--neu-space-sm); }
|
||||
|
||||
.target-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.target-name { font-weight: 600; color: var(--neu-text); font-size: var(--neu-font-md); }
|
||||
.target-vmid { font-size: var(--neu-font-xs); color: var(--neu-text-muted); font-family: monospace; }
|
||||
|
||||
.target-status { min-height: 26px; display: flex; align-items: center; gap: var(--neu-space-xs); }
|
||||
|
||||
.not-checked { font-size: var(--neu-font-xs); }
|
||||
|
||||
.pkg-count-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--neu-space-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.pkg-toggle { font-size: 10px; color: var(--neu-text-muted); }
|
||||
|
||||
.package-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: var(--neu-space-xs) var(--neu-space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.package-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
border-bottom: 1px solid var(--neu-border);
|
||||
gap: var(--neu-space-sm);
|
||||
}
|
||||
.package-item:last-child { border-bottom: none; }
|
||||
.pkg-name { font-family: monospace; color: var(--neu-text); font-weight: 500; flex-shrink: 0; }
|
||||
.pkg-versions { font-family: monospace; text-align: right; }
|
||||
.pkg-versions strong { color: var(--neu-text); }
|
||||
|
||||
.target-actions {
|
||||
display: flex;
|
||||
gap: var(--neu-space-xs);
|
||||
margin-top: auto;
|
||||
padding-top: var(--neu-space-sm);
|
||||
border-top: 1px solid var(--neu-border);
|
||||
}
|
||||
.target-actions .neu-btn { flex: 1; font-size: var(--neu-font-xs); }
|
||||
|
||||
.output-card { }
|
||||
.output-header { margin-bottom: var(--neu-space-sm); }
|
||||
|
||||
.output-terminal {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
|
|
@ -167,7 +395,6 @@ function formatDate(iso: string): string {
|
|||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
color: var(--neu-success);
|
||||
white-space: pre-wrap;
|
||||
|
|
@ -177,11 +404,14 @@ function formatDate(iso: string): string {
|
|||
|
||||
.empty-state { padding: var(--neu-space-md) 0; }
|
||||
|
||||
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-sm); }
|
||||
|
||||
.history-item { padding: var(--neu-space-sm) 0; border-bottom: 1px solid var(--neu-border); }
|
||||
.history-list { display: flex; flex-direction: column; gap: var(--neu-space-xs); }
|
||||
.history-item { padding: var(--neu-space-xs) 0; border-bottom: 1px solid var(--neu-border); }
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
|
||||
.history-target { font-size: var(--neu-font-sm); color: var(--neu-text); font-family: monospace; }
|
||||
.history-date { font-size: var(--neu-font-xs); }
|
||||
.history-date { font-size: var(--neu-font-xs); margin-left: auto; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header { flex-direction: column; }
|
||||
.targets-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue