feat: Waves 3+4 — frontend wiring, NATS integration, stores (19 files)
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Frontend:
- Wire Dashboard quick actions (start/stop/trigger wipe) + next wipe schedule
- Wire Console WebSocket streaming for real-time output
- Implement TOTP 2FA challenge flow in LoginView
- Wire Plugin load/unload toggle + uninstall buttons with confirmations
- Wire WipesView profile selector, disable trigger when no profiles
- Build full WipeProfiles create/edit modal with all config fields
- Wire MapsView file upload with multipart FormData
- Fix SettingsView empty catch blocks → toast error messages
- Fix stale localStorage token reads in CSV exports → auth store
- Fix auth store hardcoded permissions → JWT-decoded role permissions
- Fix wipe store onMounted lifecycle bug → explicit subscribe action
- Update EarlyAccessView from countdown to "Now Live" state
Backend:
- Wire wipe trigger to publish NATS cmd (corrosion.{id}.cmd.wipe)
- Wire plugin reload/uninstall to publish NATS cmd
- Expand NatsBridgeService: add files, wipe status, server status subs
- Add PATCH schedules/:id/toggle endpoint for task toggling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { PluginsController } from './plugins.controller';
|
import { PluginsController } from './plugins.controller';
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
import { PluginRegistry } from '../../entities/plugin-registry.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([PluginRegistry])],
|
imports: [TypeOrmModule.forFeature([PluginRegistry])],
|
||||||
controllers: [PluginsController],
|
controllers: [PluginsController],
|
||||||
providers: [PluginsService],
|
providers: [PluginsService, NatsService],
|
||||||
exports: [PluginsService],
|
exports: [PluginsService],
|
||||||
})
|
})
|
||||||
export class PluginsModule {}
|
export class PluginsModule {}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Patch,
|
||||||
Delete,
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +87,28 @@ export class SchedulesController {
|
|||||||
return await this.schedulesService.updateTask(licenseId, taskId, dto);
|
return await this.schedulesService.updateTask(licenseId, taskId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('tasks/:id/toggle')
|
||||||
|
@RequirePermission('schedules.manage')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Toggle a scheduled task',
|
||||||
|
description: 'Enable or disable a scheduled task without deleting it',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Task toggled successfully',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: 'Task not found',
|
||||||
|
})
|
||||||
|
async toggleTask(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) taskId: string,
|
||||||
|
@Body('enabled') enabled: boolean,
|
||||||
|
) {
|
||||||
|
return await this.schedulesService.toggleTask(licenseId, taskId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('tasks/:id')
|
@Delete('tasks/:id')
|
||||||
@RequirePermission('schedules.manage')
|
@RequirePermission('schedules.manage')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -101,7 +125,7 @@ export class SchedulesController {
|
|||||||
})
|
})
|
||||||
async deleteTask(
|
async deleteTask(
|
||||||
@CurrentTenant() licenseId: string,
|
@CurrentTenant() licenseId: string,
|
||||||
@Param('id') taskId: string,
|
@Param('id', ParseUUIDPipe) taskId: string,
|
||||||
) {
|
) {
|
||||||
return await this.schedulesService.deleteTask(licenseId, taskId);
|
return await this.schedulesService.deleteTask(licenseId, taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { WipesService } from './wipes.service';
|
|||||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||||
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
import { WipeSchedule } from '../../entities/wipe-schedule.entity';
|
||||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([WipeProfile, WipeSchedule, WipeHistory])],
|
imports: [TypeOrmModule.forFeature([WipeProfile, WipeSchedule, WipeHistory])],
|
||||||
controllers: [WipesController],
|
controllers: [WipesController],
|
||||||
providers: [WipesService],
|
providers: [WipesService, NatsService],
|
||||||
exports: [WipesService],
|
exports: [WipesService],
|
||||||
})
|
})
|
||||||
export class WipesModule {}
|
export class WipesModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
import { WipeProfile } from '../../entities/wipe-profile.entity';
|
||||||
@@ -8,9 +8,12 @@ import { CreateProfileDto } from './dto/create-profile.dto';
|
|||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WipesService {
|
export class WipesService {
|
||||||
|
private readonly logger = new Logger(WipesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WipeProfile)
|
@InjectRepository(WipeProfile)
|
||||||
private readonly wipeProfileRepo: Repository<WipeProfile>,
|
private readonly wipeProfileRepo: Repository<WipeProfile>,
|
||||||
@@ -18,6 +21,7 @@ export class WipesService {
|
|||||||
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
|
private readonly wipeScheduleRepo: Repository<WipeSchedule>,
|
||||||
@InjectRepository(WipeHistory)
|
@InjectRepository(WipeHistory)
|
||||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||||
|
private readonly natsService: NatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
async getProfiles(licenseId: string): Promise<WipeProfile[]> {
|
||||||
@@ -102,6 +106,16 @@ export class WipesService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.wipeHistoryRepo.save(history);
|
const saved = await this.wipeHistoryRepo.save(history);
|
||||||
|
|
||||||
|
await this.natsService.publish(`corrosion.${licenseId}.cmd.wipe`, {
|
||||||
|
wipe_history_id: saved.id,
|
||||||
|
wipe_type: dto.wipe_type,
|
||||||
|
wipe_profile_id: dto.wipe_profile_id ?? null,
|
||||||
|
trigger_type: 'manual',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
this.logger.log(`Wipe triggered for license ${licenseId} — history id ${saved.id}`);
|
||||||
|
|
||||||
return { wipe_history_id: saved.id };
|
return { wipe_history_id: saved.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ export class NatsBridgeService implements OnModuleInit {
|
|||||||
this.emit(licenseId, 'console_output', data);
|
this.emit(licenseId, 'console_output', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.files.response', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'files_response', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.wipe.status', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'wipe_status', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.server.status', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'server_status', data);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log('NATS bridge subscriptions initialized');
|
this.logger.log('NATS bridge subscriptions initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
|------|-------|--------|--------|
|
|------|-------|--------|--------|
|
||||||
| 1 | Critical Bug Fixes | 3 Sonnet parallel | COMPLETE |
|
| 1 | Critical Bug Fixes | 3 Sonnet parallel | COMPLETE |
|
||||||
| 2 | Missing Entities + Security | 2 Sonnet parallel | COMPLETE |
|
| 2 | Missing Entities + Security | 2 Sonnet parallel | COMPLETE |
|
||||||
| 3 | Frontend Wiring | 3 Sonnet parallel | PENDING |
|
| 3 | Frontend Wiring | 3 Sonnet parallel | COMPLETE |
|
||||||
| 4 | Backend Completion | 2 Sonnet parallel | PENDING |
|
| 4 | Backend Completion | 2 Sonnet parallel | COMPLETE |
|
||||||
| 5 | Docker + Polish | 1 Sonnet | PENDING |
|
| 5 | Docker + Polish | 1 Sonnet | PENDING |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,11 +2,35 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { User, License } from '@/types'
|
import type { User, License } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the permissions object from a JWT access token payload.
|
||||||
|
* JWTs are base64url-encoded — the payload is not secret, just signed.
|
||||||
|
* The backend JWT strategy embeds `permissions: role?.permissions || {}`
|
||||||
|
* which is a JSONB object from the Role entity (e.g. { 'server.view': true }).
|
||||||
|
* Returns an empty object if the token is missing or malformed.
|
||||||
|
*/
|
||||||
|
function decodeJwtPermissions(token: string): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const payloadB64 = token.split('.')[1]
|
||||||
|
if (!payloadB64) return {}
|
||||||
|
const json = atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
const payload = JSON.parse(json)
|
||||||
|
return payload.permissions && typeof payload.permissions === 'object'
|
||||||
|
? payload.permissions
|
||||||
|
: {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const license = ref<License | null>(null)
|
const license = ref<License | null>(null)
|
||||||
const accessToken = ref<string | null>(null)
|
const accessToken = ref<string | null>(null)
|
||||||
const refreshToken = ref<string | null>(null)
|
const refreshToken = ref<string | null>(null)
|
||||||
|
// Permissions decoded from the JWT payload — reflects the user's actual role permissions.
|
||||||
|
// Stored separately so hasPermission() works after page reload (token is persisted).
|
||||||
|
const permissions = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!accessToken.value)
|
const isAuthenticated = computed(() => !!accessToken.value)
|
||||||
const isSuperAdmin = computed(() => user.value?.is_super_admin ?? false)
|
const isSuperAdmin = computed(() => user.value?.is_super_admin ?? false)
|
||||||
@@ -17,6 +41,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
accessToken.value = data.access_token
|
accessToken.value = data.access_token
|
||||||
refreshToken.value = data.refresh_token
|
refreshToken.value = data.refresh_token
|
||||||
user.value = data.user
|
user.value = data.user
|
||||||
|
// Decode permissions from the JWT so hasPermission() reflects the real role,
|
||||||
|
// not a hardcoded list. Custom roles work automatically this way.
|
||||||
|
permissions.value = decodeJwtPermissions(data.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLicense(data: License) {
|
function setLicense(data: License) {
|
||||||
@@ -28,6 +55,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
license.value = null
|
license.value = null
|
||||||
accessToken.value = null
|
accessToken.value = null
|
||||||
refreshToken.value = null
|
refreshToken.value = null
|
||||||
|
permissions.value = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasModule(moduleSlug: string): boolean {
|
function hasModule(moduleSlug: string): boolean {
|
||||||
@@ -38,29 +66,17 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// Super admin has all permissions
|
// Super admin has all permissions
|
||||||
if (isSuperAdmin.value) return true
|
if (isSuperAdmin.value) return true
|
||||||
|
|
||||||
// Default permissions for authenticated users
|
// Check the permissions decoded from the JWT payload.
|
||||||
// In a real implementation, this would check the user's role permissions
|
// The backend embeds role permissions as a JSONB object { 'resource.action': true }.
|
||||||
// For now, grant basic permissions to all authenticated users
|
// If permissions is empty (e.g. after a page reload before re-auth), fall back to
|
||||||
const basicPermissions = [
|
// re-decoding from the persisted token so the user isn't locked out.
|
||||||
'server.view',
|
const perms = Object.keys(permissions.value).length > 0
|
||||||
'console.view',
|
? permissions.value
|
||||||
'players.view',
|
: accessToken.value
|
||||||
'plugins.view',
|
? decodeJwtPermissions(accessToken.value)
|
||||||
'wipes.view',
|
: {}
|
||||||
'maps.view',
|
|
||||||
'chat.view',
|
|
||||||
'analytics.view',
|
|
||||||
'notifications.view',
|
|
||||||
'store.view',
|
|
||||||
'modules.view',
|
|
||||||
'settings.view',
|
|
||||||
'schedules.view',
|
|
||||||
'alerts.view',
|
|
||||||
'changelog.view',
|
|
||||||
'migration.view',
|
|
||||||
]
|
|
||||||
|
|
||||||
return basicPermissions.includes(permission)
|
return perms[permission] === true
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,6 +84,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
license,
|
license,
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
permissions,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
hasLicense,
|
hasLicense,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { WipeProfile, WipeSchedule, WipeHistory } from '@/types'
|
import type { WipeProfile, WipeSchedule, WipeHistory } from '@/types'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
@@ -80,10 +80,31 @@ export const useWipeStore = defineStore('wipe', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to WebSocket events when store is initialized
|
// Track whether we've already subscribed to avoid duplicate handlers
|
||||||
onMounted(() => {
|
// when multiple components call subscribeToWipeEvents() in their onMounted.
|
||||||
websocket.subscribe(handleWebSocketMessage)
|
let unsubscribe: (() => void) | null = null
|
||||||
})
|
|
||||||
|
/**
|
||||||
|
* Subscribe to wipe-related WebSocket events.
|
||||||
|
* Call this from a component's onMounted — NOT from the store body.
|
||||||
|
* onMounted() is a Vue lifecycle hook that silently no-ops outside component
|
||||||
|
* setup context, so WebSocket subscriptions placed there in a store body
|
||||||
|
* would never fire when the store is initialized outside a component.
|
||||||
|
* Returns an unsubscribe function for cleanup in onUnmounted.
|
||||||
|
*/
|
||||||
|
function subscribeToWipeEvents(): () => void {
|
||||||
|
if (unsubscribe) {
|
||||||
|
// Already subscribed — return the existing cleanup function
|
||||||
|
return unsubscribe
|
||||||
|
}
|
||||||
|
unsubscribe = websocket.subscribe(handleWebSocketMessage)
|
||||||
|
return () => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProfiles() {
|
async function fetchProfiles() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -270,5 +291,6 @@ export const useWipeStore = defineStore('wipe', () => {
|
|||||||
createProfile,
|
createProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
deleteProfile,
|
deleteProfile,
|
||||||
|
subscribeToWipeEvents,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
|
|||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
|
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -194,7 +196,7 @@ const downloadCSV = async () => {
|
|||||||
const hours = rangeToHours(timeRange.value)
|
const hours = rangeToHours(timeRange.value)
|
||||||
const response = await fetch(`/api/analytics/export?range=${hours}`, {
|
const response = await fetch(`/api/analytics/export?range=${hours}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
'Authorization': `Bearer ${authStore.accessToken}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, onMounted } from 'vue'
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
|
const ws = useWebSocket()
|
||||||
|
|
||||||
interface ConsoleLine {
|
interface ConsoleLine {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
@@ -68,12 +70,32 @@ function lineColor(type: ConsoleLine['type']): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWebSocketMessage(message: WebSocketMessage) {
|
||||||
|
if (message.type !== 'event') return
|
||||||
|
if (message.event !== 'console_output') return
|
||||||
|
|
||||||
|
const text = message.data?.line ?? message.data?.output ?? message.raw ?? ''
|
||||||
|
if (text) {
|
||||||
|
addLine(text, 'info')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
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')
|
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||||
if (server.connection?.connection_status !== 'connected') {
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
|
const wipe = useWipeStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
server.fetchServer()
|
server.fetchServer()
|
||||||
|
try {
|
||||||
|
await wipe.fetchSchedules()
|
||||||
|
} catch {
|
||||||
|
// Non-critical — dashboard still loads without wipe data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeDate = computed<string>(() => {
|
||||||
|
const upcoming = wipe.schedules
|
||||||
|
.filter(s => s.is_active && s.next_scheduled_run)
|
||||||
|
.map(s => new Date(s.next_scheduled_run!))
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime())
|
||||||
|
|
||||||
|
if (upcoming.length === 0) return 'Not Scheduled'
|
||||||
|
|
||||||
|
return upcoming[0].toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function statusColor(status: string | undefined): string {
|
function statusColor(status: string | undefined): string {
|
||||||
@@ -67,7 +92,7 @@ function formatUptime(seconds: number | undefined): string {
|
|||||||
<!-- Next Wipe -->
|
<!-- Next Wipe -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
<p class="text-sm text-neutral-400 mb-2">Next Wipe</p>
|
<p class="text-sm text-neutral-400 mb-2">Next Wipe</p>
|
||||||
<p class="text-2xl font-bold text-neutral-100">Not Scheduled</p>
|
<p class="text-2xl font-bold text-neutral-100">{{ nextWipeDate }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime -->
|
<!-- Uptime -->
|
||||||
@@ -83,17 +108,20 @@ function formatUptime(seconds: number | undefined): string {
|
|||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
:disabled="server.connection?.connection_status === 'connected'"
|
:disabled="server.connection?.connection_status === 'connected'"
|
||||||
|
@click="server.startServer()"
|
||||||
class="px-4 py-2.5 bg-green-600/20 hover:bg-green-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-green-400 border border-green-600/30 rounded-lg text-sm font-medium transition-colors"
|
class="px-4 py-2.5 bg-green-600/20 hover:bg-green-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-green-400 border border-green-600/30 rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Start Server
|
Start Server
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="server.connection?.connection_status !== 'connected'"
|
:disabled="server.connection?.connection_status !== 'connected'"
|
||||||
|
@click="server.stopServer()"
|
||||||
class="px-4 py-2.5 bg-red-600/20 hover:bg-red-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-red-400 border border-red-600/30 rounded-lg text-sm font-medium transition-colors"
|
class="px-4 py-2.5 bg-red-600/20 hover:bg-red-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-red-400 border border-red-600/30 rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Stop Server
|
Stop Server
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
@click="router.push('/wipes')"
|
||||||
class="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-lg text-sm font-medium transition-colors"
|
class="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Trigger Wipe
|
Trigger Wipe
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { MapEntry } from '@/types'
|
import type { MapEntry } from '@/types'
|
||||||
import { Map, Upload, Trash2, RefreshCw } from 'lucide-vue-next'
|
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||||
import { safeFileSize } from '@/utils/formatters'
|
import { safeFileSize } from '@/utils/formatters'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
const maps = ref<MapEntry[]>([])
|
const maps = ref<MapEntry[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
return safeFileSize(bytes)
|
return safeFileSize(bytes)
|
||||||
@@ -35,8 +41,48 @@ async function deleteMap(map: MapEntry) {
|
|||||||
try {
|
try {
|
||||||
await api.del(`/maps/${map.id}`)
|
await api.del(`/maps/${map.id}`)
|
||||||
await fetchMaps()
|
await fetchMaps()
|
||||||
} catch {
|
toast.success(`${map.display_name} deleted`)
|
||||||
// Handle error
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete map')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Reset the input so the same file can be re-selected if needed
|
||||||
|
input.value = ''
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await fetch('/api/maps', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${auth.accessToken}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ message: 'Upload failed' }))
|
||||||
|
throw new Error(err.message || `HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`${file.name} uploaded successfully`)
|
||||||
|
await fetchMaps()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +110,21 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||||
</button>
|
</button>
|
||||||
<button class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">
|
<input
|
||||||
<Upload class="w-4 h-4" />
|
ref="fileInputRef"
|
||||||
Upload Map
|
type="file"
|
||||||
|
accept=".map"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelected"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="triggerFileInput"
|
||||||
|
:disabled="isUploading"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
||||||
|
<Upload v-else class="w-4 h-4" />
|
||||||
|
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
|
|||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
interface WipeRetentionMetric {
|
interface WipeRetentionMetric {
|
||||||
wipe_id: string
|
wipe_id: string
|
||||||
@@ -160,7 +162,7 @@ const downloadCSV = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/analytics/retention/export?wipe_count=${wipeCount.value}`, {
|
const response = await fetch(`/api/analytics/retention/export?wipe_count=${wipeCount.value}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`
|
Authorization: `Bearer ${authStore.accessToken}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { usePluginStore } from '@/stores/plugins'
|
import { usePluginStore } from '@/stores/plugins'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { PluginEntry } from '@/types'
|
import type { PluginEntry } from '@/types'
|
||||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2 } from 'lucide-vue-next'
|
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
const pluginStore = usePluginStore()
|
const pluginStore = usePluginStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const tab = ref<'installed' | 'browse'>('installed')
|
const tab = ref<'installed' | 'browse'>('installed')
|
||||||
@@ -37,6 +39,26 @@ function sourceBadgeClass(source: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleLoad(plugin: PluginEntry) {
|
||||||
|
try {
|
||||||
|
await pluginStore.reloadPlugin(plugin.id)
|
||||||
|
toast.success(`${plugin.plugin_name} ${plugin.is_loaded ? 'unloaded' : 'loaded'} successfully`)
|
||||||
|
await pluginStore.fetchPlugins()
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to toggle ${plugin.plugin_name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUninstall(plugin: PluginEntry) {
|
||||||
|
if (!confirm(`Uninstall ${plugin.plugin_name}? This cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
await pluginStore.uninstallPlugin(plugin.id)
|
||||||
|
toast.success(`${plugin.plugin_name} uninstalled`)
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to uninstall ${plugin.plugin_name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
pluginStore.fetchPlugins()
|
pluginStore.fetchPlugins()
|
||||||
})
|
})
|
||||||
@@ -144,13 +166,18 @@ onMounted(() => {
|
|||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
|
@click="handleToggleLoad(plugin)"
|
||||||
class="p-1.5 rounded transition-colors"
|
class="p-1.5 rounded transition-colors"
|
||||||
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
|
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
|
||||||
:title="plugin.is_loaded ? 'Unload' : 'Load'"
|
:title="plugin.is_loaded ? 'Unload' : 'Load'"
|
||||||
>
|
>
|
||||||
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
|
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors" title="Uninstall">
|
<button
|
||||||
|
@click="handleUninstall(plugin)"
|
||||||
|
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||||
|
title="Uninstall"
|
||||||
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Trash2 class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Settings, Key, Globe, User, Save, Loader2, Eye } from 'lucide-vue-next'
|
import { Settings, Key, Globe, User, Save, Loader2, Eye } from 'lucide-vue-next'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -49,8 +51,9 @@ async function saveAccount() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api.put('/auth/profile', accountForm.value)
|
await api.put('/auth/profile', accountForm.value)
|
||||||
} catch {
|
toast.success('Account saved successfully')
|
||||||
// Handle error
|
} catch (err) {
|
||||||
|
toast.error('Failed to save: ' + (err as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -60,8 +63,9 @@ async function saveDomain() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api.put('/settings/domain', domainForm.value)
|
await api.put('/settings/domain', domainForm.value)
|
||||||
} catch {
|
toast.success('Domain settings saved successfully')
|
||||||
// Handle error
|
} catch (err) {
|
||||||
|
toast.error('Failed to save: ' + (err as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -71,8 +75,9 @@ async function savePublicSite() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api.put('/settings/public-site', publicSiteForm.value)
|
await api.put('/settings/public-site', publicSiteForm.value)
|
||||||
} catch {
|
toast.success('Public site settings saved successfully')
|
||||||
// Handle error
|
} catch (err) {
|
||||||
|
toast.error('Failed to save: ' + (err as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,134 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
import { FileText, Plus, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { WipeProfile } from '@/types'
|
||||||
|
import { FileText, Plus, ChevronDown, ChevronRight, Edit2, Trash2, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
const wipeStore = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
const expandedId = ref<string | null>(null)
|
const expandedId = ref<string | null>(null)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingProfile = ref<WipeProfile | null>(null)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
profile_name: '',
|
||||||
|
description: '',
|
||||||
|
pre_wipe_config: {
|
||||||
|
enabled: true,
|
||||||
|
backup_before_wipe: true,
|
||||||
|
countdown_warnings: [30, 10, 5],
|
||||||
|
countdown_unit: 'minutes',
|
||||||
|
countdown_messages: {} as Record<string, string>,
|
||||||
|
kick_players_before_wipe: false,
|
||||||
|
kick_message: 'Server is wiping, back soon!',
|
||||||
|
run_final_save: true,
|
||||||
|
discord_pre_announce: false,
|
||||||
|
pushbullet_notify: false,
|
||||||
|
custom_commands_before: [] as string[],
|
||||||
|
},
|
||||||
|
post_wipe_config: {
|
||||||
|
enabled: true,
|
||||||
|
verify_server_started: true,
|
||||||
|
verify_correct_map: true,
|
||||||
|
verify_plugins_loaded: true,
|
||||||
|
verify_player_slots_open: false,
|
||||||
|
max_restart_attempts: 3,
|
||||||
|
health_check_timeout_seconds: 120,
|
||||||
|
discord_post_announce: false,
|
||||||
|
pushbullet_notify: false,
|
||||||
|
rollback_on_failure: true,
|
||||||
|
post_wipe_commands: [] as string[],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = ref(defaultForm())
|
||||||
|
|
||||||
function toggle(id: string) {
|
function toggle(id: string) {
|
||||||
expandedId.value = expandedId.value === id ? null : id
|
expandedId.value = expandedId.value === id ? null : id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
editingProfile.value = null
|
||||||
|
form.value = defaultForm()
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(profile: WipeProfile) {
|
||||||
|
editingProfile.value = profile
|
||||||
|
form.value = {
|
||||||
|
profile_name: profile.profile_name,
|
||||||
|
description: profile.description || '',
|
||||||
|
pre_wipe_config: {
|
||||||
|
enabled: profile.pre_wipe_config.enabled,
|
||||||
|
backup_before_wipe: profile.pre_wipe_config.backup_before_wipe,
|
||||||
|
countdown_warnings: [...profile.pre_wipe_config.countdown_warnings],
|
||||||
|
countdown_unit: profile.pre_wipe_config.countdown_unit,
|
||||||
|
countdown_messages: { ...profile.pre_wipe_config.countdown_messages },
|
||||||
|
kick_players_before_wipe: profile.pre_wipe_config.kick_players_before_wipe,
|
||||||
|
kick_message: profile.pre_wipe_config.kick_message,
|
||||||
|
run_final_save: profile.pre_wipe_config.run_final_save,
|
||||||
|
discord_pre_announce: profile.pre_wipe_config.discord_pre_announce,
|
||||||
|
pushbullet_notify: profile.pre_wipe_config.pushbullet_notify,
|
||||||
|
custom_commands_before: [...profile.pre_wipe_config.custom_commands_before],
|
||||||
|
},
|
||||||
|
post_wipe_config: {
|
||||||
|
enabled: profile.post_wipe_config.enabled,
|
||||||
|
verify_server_started: profile.post_wipe_config.verify_server_started,
|
||||||
|
verify_correct_map: profile.post_wipe_config.verify_correct_map,
|
||||||
|
verify_plugins_loaded: profile.post_wipe_config.verify_plugins_loaded,
|
||||||
|
verify_player_slots_open: profile.post_wipe_config.verify_player_slots_open,
|
||||||
|
max_restart_attempts: profile.post_wipe_config.max_restart_attempts,
|
||||||
|
health_check_timeout_seconds: profile.post_wipe_config.health_check_timeout_seconds,
|
||||||
|
discord_post_announce: profile.post_wipe_config.discord_post_announce,
|
||||||
|
pushbullet_notify: profile.post_wipe_config.pushbullet_notify,
|
||||||
|
rollback_on_failure: profile.post_wipe_config.rollback_on_failure,
|
||||||
|
post_wipe_commands: [...profile.post_wipe_config.post_wipe_commands],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editingProfile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
if (!form.value.profile_name.trim()) {
|
||||||
|
toast.error('Profile name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingProfile.value) {
|
||||||
|
await wipeStore.updateProfile(editingProfile.value.id, form.value)
|
||||||
|
toast.success(`Profile "${form.value.profile_name}" updated`)
|
||||||
|
} else {
|
||||||
|
await wipeStore.createProfile(form.value)
|
||||||
|
toast.success(`Profile "${form.value.profile_name}" created`)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to save profile')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProfile(profile: WipeProfile) {
|
||||||
|
if (!confirm(`Delete profile "${profile.profile_name}"? This cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
await wipeStore.deleteProfile(profile.id)
|
||||||
|
toast.success(`Profile "${profile.profile_name}" deleted`)
|
||||||
|
if (expandedId.value === profile.id) expandedId.value = null
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete profile')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
wipeStore.fetchProfiles()
|
wipeStore.fetchProfiles()
|
||||||
})
|
})
|
||||||
@@ -23,7 +142,10 @@ onMounted(() => {
|
|||||||
<FileText class="w-5 h-5 text-oxide-500" />
|
<FileText class="w-5 h-5 text-oxide-500" />
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1>
|
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1>
|
||||||
</div>
|
</div>
|
||||||
<button class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">
|
<button
|
||||||
|
@click="openCreateModal"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
New Profile
|
New Profile
|
||||||
</button>
|
</button>
|
||||||
@@ -42,9 +164,10 @@ onMounted(() => {
|
|||||||
:key="profile.id"
|
:key="profile.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"
|
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
<button
|
<button
|
||||||
@click="toggle(profile.id)"
|
@click="toggle(profile.id)"
|
||||||
class="w-full flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
|
class="flex-1 flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
|
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
|
||||||
@@ -52,6 +175,23 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
|
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
|
||||||
</button>
|
</button>
|
||||||
|
<div class="flex items-center gap-1 pr-4">
|
||||||
|
<button
|
||||||
|
@click="openEditModal(profile)"
|
||||||
|
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteProfile(profile)"
|
||||||
|
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
|
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div class="grid grid-cols-2 gap-6">
|
||||||
@@ -119,4 +259,164 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create / Edit Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showModal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
|
@click.self="closeModal"
|
||||||
|
>
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<!-- Modal header -->
|
||||||
|
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-bold text-neutral-100">{{ editingProfile ? 'Edit Profile' : 'New Profile' }}</h2>
|
||||||
|
<button @click="closeModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-neutral-300 mb-2">Profile Name</label>
|
||||||
|
<input
|
||||||
|
v-model="form.profile_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Default Wipe Profile"
|
||||||
|
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Standard wipe configuration for monthly force wipes"
|
||||||
|
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pre-Wipe Config -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Pre-Wipe</h3>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.pre_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.pre_wipe_config.backup_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Backup before wipe
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.pre_wipe_config.run_final_save" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Run final save
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.pre_wipe_config.kick_players_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Kick players before wipe
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.pre_wipe_config.discord_pre_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Discord pre-announce
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.pre_wipe_config.pushbullet_notify" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Pushbullet notify
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.pre_wipe_config.kick_players_before_wipe">
|
||||||
|
<label class="block text-sm font-medium text-neutral-300 mb-2">Kick Message</label>
|
||||||
|
<input
|
||||||
|
v-model="form.pre_wipe_config.kick_message"
|
||||||
|
type="text"
|
||||||
|
placeholder="Server is wiping, back soon!"
|
||||||
|
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post-Wipe Config -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Post-Wipe</h3>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.verify_server_started" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Verify server started
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.verify_correct_map" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Verify correct map
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.verify_plugins_loaded" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Verify plugins loaded
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.verify_player_slots_open" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Verify player slots open
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.rollback_on_failure" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Rollback on failure
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input v-model="form.post_wipe_config.discord_post_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
||||||
|
Discord post-announce
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-neutral-300 mb-2">Max Restart Attempts</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.post_wipe_config.max_restart_attempts"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-neutral-300 mb-2">Health Check Timeout (seconds)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
max="600"
|
||||||
|
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal footer -->
|
||||||
|
<div class="sticky bottom-0 bg-neutral-900 border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="saveProfile"
|
||||||
|
:disabled="isSaving"
|
||||||
|
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{{ isSaving ? 'Saving...' : (editingProfile ? 'Save Changes' : 'Create Profile') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
|
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
|
||||||
const wipeStore = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
|
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
|
||||||
|
const selectedProfileId = ref<string>('')
|
||||||
const triggerLoading = ref(false)
|
const triggerLoading = ref(false)
|
||||||
const dryRunLoading = ref(false)
|
const dryRunLoading = ref(false)
|
||||||
|
|
||||||
@@ -17,9 +20,9 @@ async function triggerWipe() {
|
|||||||
if (!confirm(`Trigger a ${triggerType.value} wipe? This cannot be undone.`)) return
|
if (!confirm(`Trigger a ${triggerType.value} wipe? This cannot be undone.`)) return
|
||||||
triggerLoading.value = true
|
triggerLoading.value = true
|
||||||
try {
|
try {
|
||||||
await wipeStore.triggerWipe(triggerType.value, '')
|
await wipeStore.triggerWipe(triggerType.value, selectedProfileId.value)
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Handle error
|
toast.error(err instanceof Error ? err.message : 'Failed to trigger wipe')
|
||||||
} finally {
|
} finally {
|
||||||
triggerLoading.value = false
|
triggerLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -28,15 +31,19 @@ async function triggerWipe() {
|
|||||||
async function triggerDryRun() {
|
async function triggerDryRun() {
|
||||||
dryRunLoading.value = true
|
dryRunLoading.value = true
|
||||||
try {
|
try {
|
||||||
await wipeStore.triggerDryRun(triggerType.value, '')
|
await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Handle error
|
toast.error(err instanceof Error ? err.message : 'Failed to run dry-run')
|
||||||
} finally {
|
} finally {
|
||||||
dryRunLoading.value = false
|
dryRunLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await wipeStore.fetchProfiles()
|
||||||
|
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
|
||||||
|
selectedProfileId.value = wipeStore.profiles[0].id
|
||||||
|
}
|
||||||
wipeStore.fetchSchedules()
|
wipeStore.fetchSchedules()
|
||||||
wipeStore.fetchHistory()
|
wipeStore.fetchHistory()
|
||||||
})
|
})
|
||||||
@@ -75,6 +82,10 @@ onMounted(() => {
|
|||||||
<!-- Manual Trigger -->
|
<!-- Manual Trigger -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<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">Manual Wipe</h2>
|
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Manual Wipe</h2>
|
||||||
|
<div v-if="wipeStore.profiles.length === 0" class="mb-4 flex items-center gap-2 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-4 py-2">
|
||||||
|
<AlertTriangle class="w-4 h-4 shrink-0" />
|
||||||
|
No wipe profiles found. <RouterLink to="/wipes/profiles" class="underline hover:text-yellow-300 ml-1">Create a profile</RouterLink> before triggering a wipe.
|
||||||
|
</div>
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
|
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
|
||||||
@@ -90,9 +101,22 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-2">Profile</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedProfileId"
|
||||||
|
:disabled="wipeStore.profiles.length === 0"
|
||||||
|
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">No profile</option>
|
||||||
|
<option v-for="profile in wipeStore.profiles" :key="profile.id" :value="profile.id">
|
||||||
|
{{ profile.profile_name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="triggerDryRun"
|
@click="triggerDryRun"
|
||||||
:disabled="dryRunLoading"
|
:disabled="dryRunLoading || wipeStore.profiles.length === 0"
|
||||||
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 border border-neutral-700 rounded-lg transition-colors"
|
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 border border-neutral-700 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="dryRunLoading" class="w-4 h-4 animate-spin" />
|
<Loader2 v-if="dryRunLoading" class="w-4 h-4 animate-spin" />
|
||||||
@@ -101,7 +125,7 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="triggerWipe"
|
@click="triggerWipe"
|
||||||
:disabled="triggerLoading || server.connection?.connection_status !== 'connected'"
|
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="triggerLoading" class="w-4 h-4 animate-spin" />
|
<Loader2 v-if="triggerLoading" class="w-4 h-4 animate-spin" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -14,6 +14,11 @@ const password = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
// TOTP state
|
||||||
|
const showTotpInput = ref(false)
|
||||||
|
const totpCode = ref('')
|
||||||
|
const totpInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -24,6 +29,15 @@ async function handleLogin() {
|
|||||||
password: password.value,
|
password: password.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.requires_totp) {
|
||||||
|
// Credentials verified — server is waiting for the TOTP code.
|
||||||
|
// Show the TOTP input and focus it.
|
||||||
|
showTotpInput.value = true
|
||||||
|
await nextTick()
|
||||||
|
totpInputEl.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
authStore.setAuth(response)
|
authStore.setAuth(response)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -36,6 +50,43 @@ async function handleLogin() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTotpVerify() {
|
||||||
|
if (totpCode.value.length !== 6 || loading.value) return
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-POST to /auth/login with the same credentials plus the TOTP code.
|
||||||
|
// The backend LoginDto accepts an optional totp_code field and returns full
|
||||||
|
// tokens when the code is valid.
|
||||||
|
const response = await api.post<AuthResponse>('/auth/login', {
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
totp_code: totpCode.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
authStore.setAuth(response)
|
||||||
|
router.push('/')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
totpCode.value = ''
|
||||||
|
if (err instanceof Error) {
|
||||||
|
error.value = err.message
|
||||||
|
} else {
|
||||||
|
error.value = 'Invalid authentication code. Please try again.'
|
||||||
|
}
|
||||||
|
// Keep the TOTP screen visible so the user can retry
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToLogin() {
|
||||||
|
showTotpInput.value = false
|
||||||
|
totpCode.value = ''
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -56,7 +107,7 @@ async function handleLogin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login form -->
|
<!-- Login form -->
|
||||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
<form v-if="!showTotpInput" @submit.prevent="handleLogin" class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
<label for="email" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
||||||
Email
|
Email
|
||||||
@@ -117,8 +168,74 @@ async function handleLogin() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- TOTP verification form -->
|
||||||
|
<div v-else class="space-y-5">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-neutral-400">
|
||||||
|
Enter the 6-digit code from your authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="totp" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
||||||
|
Authentication Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="totp"
|
||||||
|
ref="totpInputEl"
|
||||||
|
v-model="totpCode"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
placeholder="000000"
|
||||||
|
maxlength="6"
|
||||||
|
@keydown.enter="handleTotpVerify"
|
||||||
|
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors text-center tracking-widest text-lg font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="totpCode.length !== 6 || loading"
|
||||||
|
@click="handleTotpVerify"
|
||||||
|
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="loading"
|
||||||
|
class="animate-spin h-5 w-5 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ loading ? 'Verifying...' : 'Verify Code' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleBackToLogin"
|
||||||
|
class="w-full py-2 text-sm text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Register link -->
|
<!-- Register link -->
|
||||||
<p class="mt-6 text-center text-sm text-neutral-500">
|
<p v-if="!showTotpInput" class="mt-6 text-center text-sm text-neutral-500">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<router-link to="/register" class="text-oxide-400 hover:text-oxide-300 transition-colors">
|
<router-link to="/register" class="text-oxide-400 hover:text-oxide-300 transition-colors">
|
||||||
Create one
|
Create one
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
|
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
|
||||||
|
|
||||||
// ---------- Countdown ----------
|
|
||||||
const targetDate = new Date('2026-02-28T12:00:00-05:00')
|
|
||||||
const now = ref(Date.now())
|
|
||||||
let timer: ReturnType<typeof setInterval>
|
|
||||||
|
|
||||||
const countdown = computed(() => {
|
|
||||||
const diff = Math.max(0, targetDate.getTime() - now.value)
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
|
||||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
|
|
||||||
return { days, hours, minutes, seconds }
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
timer = setInterval(() => { now.value = Date.now() }, 1000)
|
|
||||||
})
|
|
||||||
onUnmounted(() => clearInterval(timer))
|
|
||||||
|
|
||||||
// ---------- Email capture ----------
|
// ---------- Email capture ----------
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const serverCount = ref('')
|
const serverCount = ref('')
|
||||||
@@ -92,19 +73,19 @@ const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.vot
|
|||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="relative overflow-hidden">
|
<section class="relative overflow-hidden">
|
||||||
<div class="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
|
<div class="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
|
||||||
<span class="inline-block px-4 py-1.5 bg-oxide-500/10 border border-oxide-500/20 rounded-full text-oxide-400 text-sm font-medium mb-6">
|
<span class="inline-block px-4 py-1.5 bg-green-500/10 border border-green-500/20 rounded-full text-green-400 text-sm font-medium mb-6">
|
||||||
Early Access Opening Soon
|
Early Access Is Now Open
|
||||||
</span>
|
</span>
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4 tracking-tight">
|
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4 tracking-tight">
|
||||||
Wipe Night Is About to<br />
|
Wipe Night Just Got<br />
|
||||||
<span class="text-oxide-500">Get Easier.</span>
|
<span class="text-oxide-500">A Lot Easier.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-neutral-400 max-w-xl mx-auto mb-10">
|
<p class="text-lg text-neutral-400 max-w-xl mx-auto mb-10">
|
||||||
Corrosion is entering limited early access. Install once. Automate everything. Never SSH again.
|
Corrosion is live in limited early access. Install once. Automate everything. Never SSH again.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center justify-center gap-4">
|
<div class="flex items-center justify-center gap-4">
|
||||||
<a href="#join" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors">
|
<a href="#join" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors">
|
||||||
Join Early Access
|
Claim Your Spot
|
||||||
</a>
|
</a>
|
||||||
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors">
|
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors">
|
||||||
View Demo Architecture
|
View Demo Architecture
|
||||||
@@ -114,39 +95,16 @@ const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.vot
|
|||||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/8 rounded-full blur-3xl pointer-events-none" />
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/8 rounded-full blur-3xl pointer-events-none" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Countdown -->
|
<!-- Early Access Live Banner -->
|
||||||
<section class="py-12 border-t border-neutral-800">
|
<section class="py-12 border-t border-neutral-800">
|
||||||
<div class="max-w-3xl mx-auto px-6 text-center">
|
<div class="max-w-3xl mx-auto px-6 text-center">
|
||||||
<p class="text-sm text-neutral-500 uppercase tracking-wider mb-6">Early Access Opens In</p>
|
<div class="inline-flex items-center gap-3 px-6 py-4 bg-green-500/10 border border-green-500/20 rounded-2xl">
|
||||||
<div class="flex items-center justify-center gap-4 md:gap-6">
|
<div class="w-2.5 h-2.5 bg-green-400 rounded-full animate-pulse shrink-0" />
|
||||||
<div class="text-center">
|
<p class="text-green-300 font-semibold text-lg">Early Access is now live — founding admin spots are limited.</p>
|
||||||
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
|
|
||||||
<span class="text-3xl font-bold text-oxide-400 tabular-nums">{{ countdown.days }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500 mt-2">Days</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-2xl text-neutral-700 font-light">:</span>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
|
|
||||||
<span class="text-3xl font-bold text-oxide-400 tabular-nums">{{ String(countdown.hours).padStart(2, '0') }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500 mt-2">Hours</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-2xl text-neutral-700 font-light">:</span>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
|
|
||||||
<span class="text-3xl font-bold text-neutral-200 tabular-nums">{{ String(countdown.minutes).padStart(2, '0') }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500 mt-2">Minutes</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-2xl text-neutral-700 font-light">:</span>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
|
|
||||||
<span class="text-3xl font-bold text-neutral-200 tabular-nums">{{ String(countdown.seconds).padStart(2, '0') }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500 mt-2">Seconds</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-neutral-500 text-sm mt-4">
|
||||||
|
Sign up below to lock in founding pricing before spots run out.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -182,13 +140,13 @@ const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.vot
|
|||||||
<!-- Email Capture -->
|
<!-- Email Capture -->
|
||||||
<section id="join" class="py-16 border-t border-neutral-800">
|
<section id="join" class="py-16 border-t border-neutral-800">
|
||||||
<div class="max-w-md mx-auto px-6">
|
<div class="max-w-md mx-auto px-6">
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Get on the List</h2>
|
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Claim Your Founding Spot</h2>
|
||||||
<p class="text-neutral-400 text-center mb-8">Be first to know when early access opens.</p>
|
<p class="text-neutral-400 text-center mb-8">Early access is open now. Spots are limited — lock in founding pricing today.</p>
|
||||||
|
|
||||||
<div v-if="submitted" class="bg-green-500/10 border border-green-500/20 rounded-xl p-8 text-center">
|
<div v-if="submitted" class="bg-green-500/10 border border-green-500/20 rounded-xl p-8 text-center">
|
||||||
<Check class="w-10 h-10 text-green-400 mx-auto mb-3" />
|
<Check class="w-10 h-10 text-green-400 mx-auto mb-3" />
|
||||||
<h3 class="text-lg font-semibold text-neutral-100 mb-1">You're on the list.</h3>
|
<h3 class="text-lg font-semibold text-neutral-100 mb-1">You're in.</h3>
|
||||||
<p class="text-sm text-neutral-400">We'll reach out when early access opens.</p>
|
<p class="text-sm text-neutral-400">We'll be in touch shortly with your access details.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
|
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
@@ -341,12 +299,12 @@ const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.vot
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4 w-px h-4 bg-neutral-800" />
|
<div class="ml-4 w-px h-4 bg-neutral-800" />
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="w-8 h-8 bg-oxide-500/10 border border-oxide-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
||||||
<ChevronRight class="w-4 h-4 text-oxide-400" />
|
<Check class="w-4 h-4 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-oxide-400">Week 2 — Early Access Opens</p>
|
<p class="text-sm font-medium text-neutral-200">Week 2 — Early Access Open</p>
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Founding Admin licenses go live.</p>
|
<p class="text-xs text-neutral-500 mt-0.5">Founding Admin licenses are live — claim yours now.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 w-px h-4 bg-neutral-800" />
|
<div class="ml-4 w-px h-4 bg-neutral-800" />
|
||||||
|
|||||||
Reference in New Issue
Block a user