feat(redesign): re-skin admin-ops/platform-admin/public views to DS (Phase D batch 4 — panel re-skin complete)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Final re-skin batch: admin ops (Console/FileManager[VueFinder preserved]/WipeCalendar/WipeHistory/Changelog/Migration), platform-admin (Dashboard/Licenses/Servers/Subscriptions/Users), public product pages (ServerInfo/StatusPage/StoreView) + PublicLayout, WarpEditor, ErrorBoundary. All logic/store/router/WebSocket/handlers preserved. Marketing views (Landing/Pricing/FAQ/HowItWorks/Roadmap/EarlyAccess + MarketingLayout) intentionally deferred to the dedicated marketing-site redesign. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { FileText, Tag, Loader2 } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
interface ChangelogEntry {
|
||||
id: string
|
||||
@@ -38,13 +42,13 @@ function loadMore() {
|
||||
fetchChangelog()
|
||||
}
|
||||
|
||||
function getCategoryColor(category: string): string {
|
||||
function categoryTone(category: string): 'online' | 'offline' | 'info' | 'warn' | 'neutral' {
|
||||
switch (category) {
|
||||
case 'feature': return 'bg-green-500/10 text-green-400'
|
||||
case 'bugfix': return 'bg-red-500/10 text-red-400'
|
||||
case 'module': return 'bg-blue-500/10 text-blue-400'
|
||||
case 'security': return 'bg-yellow-500/10 text-yellow-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
case 'feature': return 'online'
|
||||
case 'bugfix': return 'offline'
|
||||
case 'module': return 'info'
|
||||
case 'security': return 'warn'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,60 +58,127 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Changelog</h1>
|
||||
<div class="cl">
|
||||
<!-- Page head -->
|
||||
<div class="cl__head">
|
||||
<div class="cl__head-id">
|
||||
<div class="cl__head-chip">
|
||||
<Icon name="file-text" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform</div>
|
||||
<h1 class="cl__title">Changelog</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changelog Feed -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
<!-- Loading state — first load -->
|
||||
<div v-if="isLoading && entries.length === 0" class="cl__loading">
|
||||
<Icon name="loader" :size="22" class="cl__spin" />
|
||||
<span>Loading changelog…</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<Panel v-else-if="!isLoading && entries.length === 0" title="Changelog">
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="No entries yet"
|
||||
description="Platform changelog entries will appear here."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Entry feed -->
|
||||
<template v-else>
|
||||
<Panel
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
||||
:title="entry.title"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 px-2 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-lg">
|
||||
<Tag class="w-3 h-3 text-oxide-400" />
|
||||
<span class="text-xs font-mono text-oxide-400">{{ entry.version }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
||||
:class="getCategoryColor(entry.category)"
|
||||
>
|
||||
{{ entry.category }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-neutral-500">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-neutral-100 mb-2">{{ entry.title }}</h3>
|
||||
<div class="text-sm text-neutral-300 whitespace-pre-line leading-relaxed">
|
||||
{{ entry.body }}
|
||||
</div>
|
||||
<template #actions>
|
||||
<Badge tone="accent" :mono="true">{{ entry.version }}</Badge>
|
||||
<Badge :tone="categoryTone(entry.category)">{{ entry.category }}</Badge>
|
||||
<span class="cl__date">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
||||
</template>
|
||||
<div class="cl__body">{{ entry.body }}</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Load more spinner -->
|
||||
<div v-if="isLoading" class="cl__loading">
|
||||
<Icon name="loader" :size="20" class="cl__spin" />
|
||||
<span>Loading more…</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center py-6">
|
||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
||||
<!-- Load more button -->
|
||||
<div v-else-if="hasMore" class="cl__more">
|
||||
<Button variant="secondary" @click="loadMore">Load more</Button>
|
||||
</div>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-else-if="hasMore" class="flex justify-center">
|
||||
<button
|
||||
@click="loadMore"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- End of List -->
|
||||
<div v-else class="text-center py-6 text-sm text-neutral-500">
|
||||
No more changelog entries
|
||||
</div>
|
||||
</div>
|
||||
<!-- End of list -->
|
||||
<div v-else class="cl__end">No more changelog entries</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cl {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.cl__head { display: flex; align-items: center; }
|
||||
.cl__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.cl__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.cl__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Entry body */
|
||||
.cl__body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-line;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Date label in actions slot */
|
||||
.cl__date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Loading / footer states */
|
||||
.cl__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 28px 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@keyframes cl-spin { to { transform: rotate(360deg); } }
|
||||
.cl__spin { animation: cl-spin 0.7s linear infinite; }
|
||||
|
||||
.cl__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.cl__end {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const server = useServerStore()
|
||||
const ws = useWebSocket()
|
||||
|
||||
interface ConsoleLine {
|
||||
interface LogLine {
|
||||
timestamp: string
|
||||
text: string
|
||||
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
||||
}
|
||||
|
||||
const lines = ref<ConsoleLine[]>([])
|
||||
const lines = ref<LogLine[]>([])
|
||||
const commandInput = ref('')
|
||||
const consoleEl = ref<HTMLElement | null>(null)
|
||||
const sending = ref(false)
|
||||
@@ -22,7 +27,7 @@ function now(): string {
|
||||
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||
}
|
||||
|
||||
function addLine(text: string, type: ConsoleLine['type'] = 'info') {
|
||||
function addLine(text: string, type: LogLine['type'] = 'info') {
|
||||
lines.value.push({ timestamp: now(), text, type })
|
||||
scrollToBottom()
|
||||
}
|
||||
@@ -60,13 +65,12 @@ function clearConsole() {
|
||||
lines.value = []
|
||||
}
|
||||
|
||||
function lineColor(type: ConsoleLine['type']): string {
|
||||
function lineLevel(type: LogLine['type']): 'cmd' | 'warn' | 'error' | 'info' {
|
||||
switch (type) {
|
||||
case 'command': return 'text-oxide-400'
|
||||
case 'warning': return 'text-yellow-400'
|
||||
case 'error': return 'text-red-400'
|
||||
case 'system': return 'text-neutral-500'
|
||||
default: return 'text-neutral-300'
|
||||
case 'command': return 'cmd'
|
||||
case 'warning': return 'warn'
|
||||
case 'error': return 'error'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +87,10 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
addLine('Corrosion Console initialized.', 'system')
|
||||
addLine('Corrosion console initialized.', 'system')
|
||||
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||
if (server.connection?.connection_status !== 'connected') {
|
||||
addLine('WARNING: Server is not connected. Commands will fail.', 'warning')
|
||||
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||
}
|
||||
unsubscribe = ws.subscribe(handleWebSocketMessage)
|
||||
})
|
||||
@@ -100,70 +104,127 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Terminal class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Server Console</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="server.connection?.connection_status === 'connected' ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-neutral-400">
|
||||
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
<div class="cv">
|
||||
<!-- Page head -->
|
||||
<div class="cv__head">
|
||||
<div class="cv__head-id">
|
||||
<div class="cv__head-chip">
|
||||
<Icon name="terminal" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<button
|
||||
@click="clearConsole"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
<div>
|
||||
<div class="t-eyebrow">Server management</div>
|
||||
<h1 class="cv__title">Console</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv__head-actions">
|
||||
<Badge
|
||||
:tone="server.connection?.connection_status === 'connected' ? 'online' : 'offline'"
|
||||
:dot="true"
|
||||
:pulse="server.connection?.connection_status === 'connected'"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Clear
|
||||
</button>
|
||||
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" icon="trash-2" @click="clearConsole">Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console output -->
|
||||
<div
|
||||
ref="consoleEl"
|
||||
class="flex-1 bg-black/80 border border-neutral-800 rounded-t-lg p-4 overflow-y-auto font-mono text-sm leading-relaxed min-h-0"
|
||||
>
|
||||
<div v-if="lines.length === 0" class="text-neutral-600 italic">
|
||||
No output yet. Send a command to get started.
|
||||
<!-- Console panel -->
|
||||
<Panel :flush-body="true" title="Output">
|
||||
<div ref="consoleEl" class="cv__output">
|
||||
<div v-if="lines.length === 0" class="cv__empty">
|
||||
No output yet. Send a command to get started.
|
||||
</div>
|
||||
<ConsoleLine
|
||||
v-for="(line, i) in lines"
|
||||
:key="i"
|
||||
:time="line.timestamp"
|
||||
:level="lineLevel(line.type)"
|
||||
>{{ line.text }}</ConsoleLine>
|
||||
</div>
|
||||
<div
|
||||
v-for="(line, i) in lines"
|
||||
:key="i"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<span class="text-neutral-600 shrink-0 select-none">{{ line.timestamp }}</span>
|
||||
<span :class="lineColor(line.type)" class="whitespace-pre-wrap break-all">{{ line.text }}</span>
|
||||
<div class="cv__bar">
|
||||
<span class="cv__bar-prompt">$</span>
|
||||
<Input
|
||||
v-model="commandInput"
|
||||
:mono="true"
|
||||
size="sm"
|
||||
placeholder="say Hello everyone…"
|
||||
:disabled="sending"
|
||||
style="flex: 1"
|
||||
@keydown.enter="handleSend"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="corner-down-left"
|
||||
:loading="sending"
|
||||
:disabled="!commandInput.trim() || sending"
|
||||
@click="handleSend"
|
||||
>Send</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command input -->
|
||||
<div class="flex bg-neutral-900 border border-t-0 border-neutral-800 rounded-b-lg overflow-hidden">
|
||||
<span class="flex items-center px-3 text-oxide-500 font-mono text-sm select-none">$</span>
|
||||
<input
|
||||
v-model="commandInput"
|
||||
@keydown.enter="handleSend"
|
||||
type="text"
|
||||
placeholder="say Hello everyone..."
|
||||
:disabled="sending"
|
||||
class="flex-1 bg-transparent py-3 text-neutral-100 placeholder-neutral-600 font-mono text-sm focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
@click="handleSend"
|
||||
:disabled="!commandInput.trim() || sending"
|
||||
class="flex items-center gap-2 px-4 text-sm font-medium text-oxide-400 hover:text-oxide-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send class="w-4 h-4" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cv {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.cv__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.cv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.cv__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.cv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.cv__head-actions { display: flex; align-items: center; gap: 9px; }
|
||||
|
||||
/* Output area */
|
||||
.cv__output {
|
||||
min-height: 420px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cv__empty {
|
||||
padding: 16px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Command bar */
|
||||
.cv__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
.cv__bar-prompt {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-text);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -26,18 +27,22 @@ const finderConfig = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">File Manager</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">Browse and edit your server files</p>
|
||||
<div class="fm">
|
||||
<!-- Page head -->
|
||||
<div class="fm__head">
|
||||
<div class="fm__head-id">
|
||||
<div class="fm__head-chip">
|
||||
<Icon name="folder-open" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Server management</div>
|
||||
<h1 class="fm__title">File manager</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden"
|
||||
style="min-height: 640px;"
|
||||
>
|
||||
<!-- VueFinder wrapper — only the outer chrome is re-skinned; internals untouched -->
|
||||
<div class="fm__finder">
|
||||
<VueFinder
|
||||
id="corrosion-filemanager"
|
||||
:driver="driver"
|
||||
@@ -47,3 +52,36 @@ const finderConfig = {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fm {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.fm__head { display: flex; align-items: center; gap: 12px; }
|
||||
.fm__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.fm__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.fm__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Finder container — surface panel chrome, VueFinder renders inside */
|
||||
.fm__finder {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
min-height: 640px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Download, Upload, FileText, Loader2 } from 'lucide-vue-next'
|
||||
import { safeFileSize, safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
interface ExportRecord {
|
||||
id: string
|
||||
@@ -20,6 +25,8 @@ const isExporting = ref(false)
|
||||
const isImporting = ref(false)
|
||||
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const importError = ref<string | null>(null)
|
||||
const importSuccess = ref(false)
|
||||
|
||||
async function fetchExports() {
|
||||
exports.value = await api.get<ExportRecord[]>('/migration/exports')
|
||||
@@ -49,6 +56,8 @@ async function importData() {
|
||||
if (!confirm('Import data? This will overwrite existing configuration.')) return
|
||||
|
||||
isImporting.value = true
|
||||
importError.value = null
|
||||
importSuccess.value = false
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.value)
|
||||
@@ -63,18 +72,20 @@ async function importData() {
|
||||
throw new Error('Import failed')
|
||||
}
|
||||
|
||||
alert('Import successful')
|
||||
importSuccess.value = true
|
||||
uploadFile.value = null
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Import failed')
|
||||
importError.value = err instanceof Error ? err.message : 'Import failed'
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
return safeFileSize(bytes)
|
||||
}
|
||||
const EXPORT_TYPE_OPTIONS = [
|
||||
{ value: 'full' as const, label: 'Full' },
|
||||
{ value: 'config_only' as const, label: 'Config only' },
|
||||
{ value: 'store_only' as const, label: 'Store only' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchExports()
|
||||
@@ -82,110 +93,251 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Migration</h1>
|
||||
<div class="mv">
|
||||
<!-- Page head -->
|
||||
<div class="mv__head">
|
||||
<div class="mv__head-id">
|
||||
<div class="mv__head-chip">
|
||||
<Icon name="upload" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Platform</div>
|
||||
<h1 class="mv__title">Migration</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Export Data</h2>
|
||||
<div class="flex items-end gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-2">Export Type</label>
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<!-- Export section -->
|
||||
<Panel title="Export data" subtitle="Create a portable backup of your configuration">
|
||||
<div class="mv__export-row">
|
||||
<div class="mv__export-field">
|
||||
<div class="mv__field-label">Export type</div>
|
||||
<div class="mv__seg">
|
||||
<button
|
||||
v-for="opt in (['full', 'config_only', 'store_only'] as const)"
|
||||
:key="opt"
|
||||
@click="exportType = opt"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
|
||||
:class="exportType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ opt.replace('_', ' ') }}
|
||||
</button>
|
||||
v-for="opt in EXPORT_TYPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="mv__seg-btn"
|
||||
:class="exportType === opt.value && 'mv__seg-btn--active'"
|
||||
@click="exportType = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="createExport"
|
||||
<Button
|
||||
icon="download"
|
||||
:loading="isExporting"
|
||||
:disabled="isExporting"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isExporting" class="w-4 h-4 animate-spin" />
|
||||
<Download v-else class="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
@click="createExport"
|
||||
>Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Export History -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="p-5 border-b border-neutral-800">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Export History</h2>
|
||||
</div>
|
||||
<div v-if="exports.length === 0" class="p-8 text-center text-neutral-500">
|
||||
No exports yet.
|
||||
</div>
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
||||
<!-- Export history -->
|
||||
<Panel :flush-body="true" title="Export history">
|
||||
<EmptyState
|
||||
v-if="exports.length === 0"
|
||||
icon="file-text"
|
||||
title="No exports yet"
|
||||
description="Create an export above to see it listed here."
|
||||
/>
|
||||
<table v-else class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Created</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Size</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="exp in exports" :key="exp.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-200 capitalize">{{ exp.export_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(exp.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatBytes(exp.file_size_bytes) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<tbody>
|
||||
<tr v-for="exp in exports" :key="exp.id">
|
||||
<td>
|
||||
<Badge tone="neutral">{{ exp.export_type.replace('_', ' ') }}</Badge>
|
||||
</td>
|
||||
<td class="td-mono">{{ safeDate(exp.created_at) }}</td>
|
||||
<td class="td-mono">{{ safeFileSize(exp.file_size_bytes) }}</td>
|
||||
<td>
|
||||
<a
|
||||
v-if="exp.download_url"
|
||||
:href="exp.download_url"
|
||||
class="text-oxide-400 hover:text-oxide-300 text-sm transition-colors"
|
||||
class="mv__dl-link"
|
||||
>
|
||||
<Icon name="download" :size="13" />
|
||||
Download
|
||||
</a>
|
||||
<span v-else class="text-sm text-neutral-600">Preparing...</span>
|
||||
<span v-else class="mv__preparing">Preparing…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Import Section -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Import Data</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="border-2 border-dashed border-neutral-700 rounded-lg p-6 text-center">
|
||||
<Upload class="w-8 h-8 text-neutral-500 mx-auto mb-2" />
|
||||
<!-- Import section -->
|
||||
<Panel title="Import data" subtitle="Restore from a JSON or ZIP export file">
|
||||
<div class="mv__import-body">
|
||||
<Alert v-if="importSuccess" tone="online">Import completed successfully.</Alert>
|
||||
<Alert v-if="importError" tone="danger">{{ importError }}</Alert>
|
||||
|
||||
<label for="file-upload" class="mv__dropzone">
|
||||
<Icon name="upload" :size="28" class="mv__dropzone-icon" />
|
||||
<span class="mv__dropzone-label">
|
||||
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
||||
</span>
|
||||
<span class="mv__dropzone-hint">JSON or ZIP exports</span>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".json,.zip"
|
||||
class="mv__file-input"
|
||||
@change="handleFileSelect"
|
||||
class="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label for="file-upload" class="cursor-pointer">
|
||||
<span class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
|
||||
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs text-neutral-500 mt-1">JSON or ZIP exports</p>
|
||||
</div>
|
||||
<button
|
||||
@click="importData"
|
||||
</label>
|
||||
|
||||
<Button
|
||||
:block="true"
|
||||
icon="upload"
|
||||
:loading="isImporting"
|
||||
:disabled="!uploadFile || isImporting"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
|
||||
<Upload v-else class="w-4 h-4" />
|
||||
Import
|
||||
</button>
|
||||
@click="importData"
|
||||
>Import</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mv {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.mv__head { display: flex; align-items: center; }
|
||||
.mv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.mv__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.mv__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Export row */
|
||||
.mv__export-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mv__export-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.mv__field-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Segment control (mirrors WipesView pattern) */
|
||||
.mv__seg {
|
||||
display: flex;
|
||||
background: var(--surface-inset);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mv__seg-btn {
|
||||
height: var(--control-h-md);
|
||||
padding: 0 14px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.mv__seg-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||
.mv__seg-btn--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-text);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Download link */
|
||||
.mv__dl-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--accent-text);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.mv__dl-link:hover { color: var(--accent); }
|
||||
.mv__preparing { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
|
||||
/* Import body */
|
||||
.mv__import-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Dropzone */
|
||||
.mv__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 32px 24px;
|
||||
border: 1.5px dashed var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
position: relative;
|
||||
}
|
||||
.mv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
|
||||
.mv__dropzone-icon { color: var(--text-tertiary); }
|
||||
.mv__dropzone:hover .mv__dropzone-icon { color: var(--accent-text); }
|
||||
.mv__dropzone-label { font-size: var(--text-sm); font-weight: 500; color: var(--accent-text); }
|
||||
.mv__dropzone-hint { font-size: var(--text-xs); color: var(--text-muted); }
|
||||
.mv__file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
|
||||
@@ -53,6 +57,8 @@ function nextMonth() {
|
||||
currentMonth.value = d
|
||||
}
|
||||
|
||||
const activeSchedules = computed(() => wipeStore.schedules.filter(s => s.is_active))
|
||||
|
||||
onMounted(() => {
|
||||
wipeStore.fetchHistory()
|
||||
wipeStore.fetchSchedules()
|
||||
@@ -60,79 +66,177 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Calendar class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Calendar</h1>
|
||||
<div class="wc">
|
||||
<!-- Page head -->
|
||||
<div class="wc__head">
|
||||
<div class="wc__head-id">
|
||||
<div class="wc__head-chip">
|
||||
<Icon name="calendar" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="t-eyebrow">Auto-wiper</div>
|
||||
<h1 class="wc__title">Wipe calendar</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month navigation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<button @click="prevMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
|
||||
<ChevronLeft class="w-5 h-5" />
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-neutral-200">{{ monthLabel }}</h2>
|
||||
<button @click="nextMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
|
||||
<ChevronRight class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Calendar panel -->
|
||||
<Panel :flush-body="true" title="Wipe calendar">
|
||||
<template #actions>
|
||||
<Button variant="ghost" size="sm" icon="chevron-left" @click="prevMonth" />
|
||||
<span class="wc__month-label">{{ monthLabel }}</span>
|
||||
<Button variant="ghost" size="sm" icon="chevron-right" @click="nextMonth" />
|
||||
</template>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<!-- Day headers -->
|
||||
<div class="grid grid-cols-7 border-b border-neutral-800">
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="wc__dow-row">
|
||||
<div
|
||||
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="day"
|
||||
class="py-2 text-center text-xs font-medium text-neutral-500 uppercase"
|
||||
>
|
||||
{{ day }}
|
||||
</div>
|
||||
class="wc__dow"
|
||||
>{{ day }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Day cells -->
|
||||
<div class="grid grid-cols-7">
|
||||
<div class="wc__grid">
|
||||
<div
|
||||
v-for="(day, i) in calendarDays"
|
||||
:key="i"
|
||||
class="min-h-20 p-2 border-b border-r border-neutral-800 last:border-r-0"
|
||||
:class="{ 'bg-neutral-800/30': !day.inMonth }"
|
||||
class="wc__cell"
|
||||
:class="{ 'wc__cell--out': !day.inMonth }"
|
||||
>
|
||||
<template v-if="day.inMonth">
|
||||
<p class="text-sm" :class="day.hasWipe ? 'text-oxide-400 font-bold' : 'text-neutral-400'">
|
||||
{{ day.date }}
|
||||
</p>
|
||||
<div v-if="day.hasWipe" class="mt-1">
|
||||
<span class="text-xs bg-oxide-500/15 text-oxide-400 px-1.5 py-0.5 rounded">
|
||||
{{ day.wipeType }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="wc__date" :class="{ 'wc__date--wipe': day.hasWipe }">{{ day.date }}</span>
|
||||
<Badge
|
||||
v-if="day.hasWipe"
|
||||
tone="warn"
|
||||
size="md"
|
||||
class="wc__wipe-badge"
|
||||
>{{ day.wipeType }}</Badge>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Upcoming schedules -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Active Schedules</h2>
|
||||
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 text-center py-4">
|
||||
No active schedules.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<!-- Active schedules panel -->
|
||||
<Panel title="Active schedules" subtitle="Cron schedules currently enabled">
|
||||
<EmptyState
|
||||
v-if="activeSchedules.length === 0"
|
||||
icon="calendar-clock"
|
||||
title="No active schedules"
|
||||
description="Activate a schedule in the auto-wiper to see it here."
|
||||
/>
|
||||
<div v-else class="wc__sched-list">
|
||||
<div
|
||||
v-for="schedule in wipeStore.schedules.filter(s => s.is_active)"
|
||||
v-for="schedule in activeSchedules"
|
||||
:key="schedule.id"
|
||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
||||
class="wc__sched-row"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">{{ schedule.schedule_name }}</p>
|
||||
<p class="text-xs text-neutral-500 font-mono">{{ schedule.cron_expression }}</p>
|
||||
<div class="wc__sched-info">
|
||||
<div class="wc__sched-name">{{ schedule.schedule_name }}</div>
|
||||
<div class="wc__sched-meta">
|
||||
<span class="wc__mono">{{ schedule.cron_expression }}</span>
|
||||
· {{ schedule.timezone }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wc__sched-next">
|
||||
Next:
|
||||
<span class="wc__mono">
|
||||
{{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-400">
|
||||
Next: {{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wc {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.wc__head { display: flex; align-items: center; }
|
||||
.wc__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.wc__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.wc__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.wc__month-label {
|
||||
font-size: var(--text-sm); font-weight: 600; color: var(--text-primary);
|
||||
padding: 0 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Calendar grid */
|
||||
.wc__dow-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.wc__dow {
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
|
||||
.wc__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
.wc__cell {
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wc__cell:nth-child(7n) { border-right: 0; }
|
||||
.wc__cell--out { background: var(--surface-inset); }
|
||||
|
||||
.wc__date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wc__date--wipe { color: var(--accent-text); font-weight: 700; }
|
||||
.wc__wipe-badge { align-self: flex-start; }
|
||||
|
||||
/* Active schedules list */
|
||||
.wc__sched-list { display: flex; flex-direction: column; }
|
||||
.wc__sched-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.wc__sched-row:last-child { border-bottom: 0; }
|
||||
.wc__sched-info { flex: 1; min-width: 0; }
|
||||
.wc__sched-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.wc__sched-meta { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||
.wc__sched-next { font-size: var(--text-xs); color: var(--text-tertiary); flex: none; }
|
||||
.wc__mono { font-family: var(--font-mono); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wc__cell { min-height: 52px; padding: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { History, RefreshCw } from 'lucide-vue-next'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'success': return 'bg-green-500/10 text-green-400'
|
||||
case 'failed':
|
||||
case 'rolled_back': return 'bg-red-500/10 text-red-400'
|
||||
case 'wiping':
|
||||
case 'pre_wipe':
|
||||
case 'post_wipe': return 'bg-yellow-500/10 text-yellow-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
}
|
||||
function wipeTone(status: string): 'online' | 'offline' | 'warn' | 'neutral' {
|
||||
if (status === 'success') return 'online'
|
||||
if (status === 'failed' || status === 'rolled_back') return 'offline'
|
||||
if (status === 'wiping' || status === 'pre_wipe' || status === 'post_wipe') return 'warn'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function duration(start: string | null, end: string | null): string {
|
||||
if (!start || !end) return '\u2014'
|
||||
if (!start || !end) return '—'
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
const s = Math.floor(ms / 1000)
|
||||
const m = Math.floor(s / 60)
|
||||
@@ -33,68 +32,154 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<History class="w-5 h-5 text-oxide-500" />
|
||||
<div class="wh">
|
||||
<!-- Page head -->
|
||||
<div class="wh__head">
|
||||
<div class="wh__head-id">
|
||||
<div class="wh__head-chip">
|
||||
<Icon name="clock" :size="20" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe History</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">{{ wipeStore.history.length }} wipes recorded</p>
|
||||
<div class="t-eyebrow">Auto-wiper</div>
|
||||
<h1 class="wh__title">Wipe history</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="wipeStore.fetchHistory()"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh-cw"
|
||||
:loading="wipeStore.isLoading"
|
||||
:disabled="wipeStore.isLoading"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': wipeStore.isLoading }" />
|
||||
Refresh
|
||||
</button>
|
||||
@click="wipeStore.fetchHistory()"
|
||||
>Refresh</Button>
|
||||
</div>
|
||||
|
||||
<!-- Summary line -->
|
||||
<div v-if="!wipeStore.isLoading" class="wh__summary">
|
||||
{{ wipeStore.history.length }} wipe{{ wipeStore.history.length === 1 ? '' : 's' }} recorded
|
||||
</div>
|
||||
|
||||
<!-- History table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<Panel :flush-body="true" title="All wipes">
|
||||
<EmptyState
|
||||
v-if="wipeStore.history.length === 0 && !wipeStore.isLoading"
|
||||
icon="trash-2"
|
||||
title="No wipe history"
|
||||
description="Wipes will appear here once they run."
|
||||
/>
|
||||
|
||||
<div v-else-if="wipeStore.isLoading" class="wh__loading">
|
||||
<Icon name="loader" :size="20" class="wh__spin" />
|
||||
<span>Loading history…</span>
|
||||
</div>
|
||||
|
||||
<table v-else class="cc-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Trigger</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Started</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Duration</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Map</th>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Trigger</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Map</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-if="wipeStore.history.length === 0">
|
||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="wipeStore.isLoading">Loading history...</template>
|
||||
<template v-else>No wipe history yet.</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="wipe in wipeStore.history"
|
||||
:key="wipe.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100 capitalize">{{ wipe.wipe_type }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ wipe.trigger_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="statusBadgeClass(wipe.status)">
|
||||
{{ wipe.status.replace('_', ' ') }}
|
||||
</span>
|
||||
<td class="td-primary">{{ wipe.wipe_type }}</td>
|
||||
<td>{{ wipe.trigger_type.replace('_', ' ') }}</td>
|
||||
<td>
|
||||
<Badge :tone="wipeTone(wipe.status)">{{ wipe.status.replace('_', ' ') }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||
{{ safeDate(wipe.started_at, '\u2014') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
||||
{{ duration(wipe.started_at, wipe.completed_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ wipe.map_used || '\u2014' }}</td>
|
||||
<td class="td-mono">{{ safeDate(wipe.started_at, '—') }}</td>
|
||||
<td class="td-mono">{{ duration(wipe.started_at, wipe.completed_at) }}</td>
|
||||
<td>{{ wipe.map_used || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wh {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Page head */
|
||||
.wh__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.wh__head-id { display: flex; align-items: center; gap: 12px; }
|
||||
.wh__head-chip {
|
||||
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.wh__title {
|
||||
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 3px;
|
||||
}
|
||||
.wh__summary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.wh__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 36px 16px;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@keyframes wh-spin { to { transform: rotate(360deg); } }
|
||||
.wh__spin { animation: wh-spin 0.7s linear infinite; }
|
||||
|
||||
/* Table */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.cc-table thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-inset);
|
||||
}
|
||||
.cc-table th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cc-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.cc-table td {
|
||||
padding: 11px 16px;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.td-primary { color: var(--text-primary); font-weight: 500; text-transform: capitalize; }
|
||||
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user