feat: add vue-i18n infrastructure and externalize all UI strings (A11Y-03)
Set up vue-i18n with English locale file containing 500+ keys organized by view namespace. All 15 views converted to use t() calls instead of hardcoded strings. Infrastructure ready for community translations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
15
neode-ui/src/i18n.ts
Normal file
15
neode-ui/src/i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en.json'
|
||||
|
||||
export type MessageSchema = typeof en
|
||||
|
||||
const i18n = createI18n<[MessageSchema], 'en'>({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
696
neode-ui/src/locales/en.json
Normal file
696
neode-ui/src/locales/en.json
Normal file
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copiedBang": "Copied!",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"uninstall": "Uninstall",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"launch": "Launch",
|
||||
"starting": "Starting...",
|
||||
"stopping": "Stopping...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"manage": "Manage",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnect",
|
||||
"running": "running",
|
||||
"stopped": "stopped",
|
||||
"exited": "exited",
|
||||
"healthy": "Healthy",
|
||||
"elevated": "Elevated",
|
||||
"critical": "Critical",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"synced": "Synced",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"dismiss": "Dismiss",
|
||||
"apply": "Apply",
|
||||
"configure": "Configure",
|
||||
"export": "Export",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"error": "Error",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"category": "Category",
|
||||
"developer": "Developer",
|
||||
"license": "License",
|
||||
"never": "Never",
|
||||
"notAvailable": "Not Available",
|
||||
"goBack": "Go back",
|
||||
"skipToContent": "Skip to main content",
|
||||
"continue": "Continue",
|
||||
"verify": "Verify",
|
||||
"create": "Create",
|
||||
"restore": "Restore",
|
||||
"disabling": "Disabling...",
|
||||
"creating": "Creating...",
|
||||
"restoring": "Restoring...",
|
||||
"manageUpdates": "Manage Updates",
|
||||
"enableAll": "Enable All",
|
||||
"networkDiagnostics": "Network Diagnostics",
|
||||
"network": "Network",
|
||||
"saveConfiguration": "Save Configuration",
|
||||
"sendTest": "Send Test"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome to Archipelago",
|
||||
"setupTitle": "Set Up Your Node",
|
||||
"twoFactorTitle": "Two-Factor Authentication",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"enterPasswordPlaceholder": "Enter your password",
|
||||
"enterPasswordSetup": "Enter a password (min 8 characters)",
|
||||
"confirmPasswordPlaceholder": "Confirm your password",
|
||||
"setupButton": "Set Up Node",
|
||||
"settingUp": "Setting up...",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"verifyButton": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"useAuthCode": "Use authenticator code",
|
||||
"useBackupCode": "Use a backup code instead",
|
||||
"totpInstruction": "Enter the 6-digit code from your authenticator app",
|
||||
"totpPlaceholder": "000000",
|
||||
"backupCodePlaceholder": "XXXX-XXXX",
|
||||
"serverStarting": "Server starting up...",
|
||||
"replayIntro": "Replay Intro",
|
||||
"onboarding": "Onboarding",
|
||||
"resetting": "Resetting...",
|
||||
"recoveryNote": "Password recovery requires SSH access to the server.",
|
||||
"errorMinLength": "Password must be at least 8 characters",
|
||||
"errorMismatch": "Passwords do not match",
|
||||
"errorServerStarting": "Server is starting up. Please try again in a moment.",
|
||||
"errorSetupFailed": "Setup failed. Please try again.",
|
||||
"errorLoginFailed": "Login failed. Please check your password.",
|
||||
"errorInvalidCode": "Invalid code. Please try again.",
|
||||
"totpLabel": "Two-factor authentication code"
|
||||
},
|
||||
"home": {
|
||||
"title": "Welcome Noderunner",
|
||||
"subtitle": "Here's an overview of your sovereign life",
|
||||
"dashboardTab": "Dashboard",
|
||||
"setupTab": "Setup",
|
||||
"myApps": "My Apps",
|
||||
"myAppsDesc": "Manage your installed applications",
|
||||
"cloud": "Cloud",
|
||||
"cloudDesc": "Cloud services and storage",
|
||||
"network": "Network",
|
||||
"networkDesc": "Network infrastructure and Web3 services",
|
||||
"web5": "Web5",
|
||||
"web5Desc": "Decentralized identity and data protocols",
|
||||
"system": "System",
|
||||
"quickStartGoals": "Quick Start Goals",
|
||||
"quickStartDesc": "Not sure where to start? Try a guided setup.",
|
||||
"installed": "Installed",
|
||||
"runningLabel": "Running",
|
||||
"storageUsed": "Storage Used",
|
||||
"folders": "Folders",
|
||||
"servicesStatus": "Services Status",
|
||||
"connectivity": "Connectivity",
|
||||
"runningApps": "Running Apps",
|
||||
"didStatus": "DID Status",
|
||||
"dwnSync": "DWN Sync",
|
||||
"credentials": "Credentials",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"disk": "Disk",
|
||||
"browseStore": "Browse Store",
|
||||
"manageApps": "Manage Apps",
|
||||
"viewFolders": "View Folders",
|
||||
"uploadFiles": "Upload Files",
|
||||
"manageNetwork": "Manage Network",
|
||||
"manageWeb5": "Manage Web5",
|
||||
"openAI": "Open AI Assistant",
|
||||
"noApps": "No Apps",
|
||||
"allRunning": "All Running",
|
||||
"systemMonitoring": "System monitoring",
|
||||
"updateAvailable": "Update Available: v{version}",
|
||||
"updateNow": "Update Now",
|
||||
"goToApps": "Go to Apps",
|
||||
"goToCloud": "Go to Cloud",
|
||||
"goToNetwork": "Go to Network",
|
||||
"goToWeb5": "Go to Web5",
|
||||
"goToSettings": "Go to Settings"
|
||||
},
|
||||
"apps": {
|
||||
"title": "My Apps",
|
||||
"subtitle": "Manage your installed applications",
|
||||
"searchPlaceholder": "Search installed apps...",
|
||||
"noAppsTitle": "No Apps Installed",
|
||||
"noAppsMessage": "Get started by browsing the app store",
|
||||
"browseAppStore": "Browse App Store",
|
||||
"noResults": "No apps matching \"{query}\"",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"dismissError": "Dismiss error",
|
||||
"searchLabel": "Search installed apps"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure your Archipelago experience",
|
||||
"account": "Account",
|
||||
"interfaceMode": "Interface Mode",
|
||||
"claudeAuth": "Claude Authentication",
|
||||
"aiDataAccess": "AI Data Access",
|
||||
"serverName": "Server Name",
|
||||
"sessionStatus": "Session Status",
|
||||
"yourDid": "Your DID",
|
||||
"onionAddress": "Node .onion Address",
|
||||
"loggedIn": "Currently logged in",
|
||||
"didHelper": "Decentralized identifier for passwordless auth",
|
||||
"onionHelper": "Onion address for node interface and peer discovery over Tor",
|
||||
"changePassword": "Change Password",
|
||||
"enable2fa": "Enable 2FA",
|
||||
"disable2fa": "Disable 2FA",
|
||||
"logout": "Logout",
|
||||
"loggingOut": "Logging out...",
|
||||
"twoFactorAuth": "Two-Factor Authentication",
|
||||
"twoFaProtect": "Protect your account with an authenticator app",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"changePasswordDesc": "Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm New Password",
|
||||
"passwordPlaceholder": "12+ chars, upper, lower, digit, special",
|
||||
"updateSshCheckbox": "Also update SSH password (recommended)",
|
||||
"updatePassword": "Update Password",
|
||||
"updatingPassword": "Updating...",
|
||||
"setup2faTitle": "Two-Factor Authentication",
|
||||
"setup2faPasswordPrompt": "Enter your password to begin setup.",
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"scanQrInstruction": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.",
|
||||
"manualEntryKey": "Manual entry key:",
|
||||
"verifyAndEnable": "Verify & Enable",
|
||||
"saveBackupCodes": "Save Your Backup Codes",
|
||||
"backupCodesInstruction": "Store these codes safely. Each can be used once if you lose access to your authenticator app.",
|
||||
"copyAllCodes": "Copy All Codes",
|
||||
"disable2faTitle": "Disable Two-Factor Authentication",
|
||||
"disable2faDesc": "Enter your password and a current TOTP code to disable 2FA.",
|
||||
"authenticatorCode": "Authenticator Code",
|
||||
"webhooks": "Webhooks",
|
||||
"webhooksDesc": "Get notified when important events happen on your node",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlPlaceholder": "https://example.com/webhook",
|
||||
"webhookSecret": "Secret (for HMAC signing)",
|
||||
"webhookSecretPlaceholder": "Optional shared secret",
|
||||
"webhookEvents": "Events",
|
||||
"containerCrash": "Container Crash",
|
||||
"updateAvailableEvent": "Update Available",
|
||||
"diskWarning": "Disk Warning",
|
||||
"backupComplete": "Backup Complete",
|
||||
"saveWebhook": "Save",
|
||||
"savingWebhook": "Saving...",
|
||||
"testWebhook": "Test",
|
||||
"testingWebhook": "Testing...",
|
||||
"webhookSaved": "Webhook configuration saved",
|
||||
"webhookTestSent": "Test webhook sent successfully",
|
||||
"systemUpdates": "System Updates",
|
||||
"backup": "Backup & Restore",
|
||||
"backupDesc": "Back up your node data to external storage",
|
||||
"createBackup": "Create Backup",
|
||||
"creatingBackup": "Creating...",
|
||||
"restoreBackup": "Restore Backup",
|
||||
"deleteBackup": "Delete backup",
|
||||
"backupCreated": "Backup created successfully",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Broadcast Message",
|
||||
"messagePlaceholder": "Enter your message...",
|
||||
"messageSent": "Message sent",
|
||||
"claudeConnected": "Connected to Claude",
|
||||
"claudeDisconnected": "Not connected",
|
||||
"claudeApiKey": "API Key",
|
||||
"claudeApiKeyPlaceholder": "Enter your Anthropic API key",
|
||||
"claudeSave": "Save Key",
|
||||
"advancedMode": "Advanced Mode",
|
||||
"beginnerMode": "Beginner Mode",
|
||||
"advancedModeDesc": "Show all system controls and developer tools",
|
||||
"beginnerModeDesc": "Simplified interface with guided experience",
|
||||
"networkSettings": "Network Settings",
|
||||
"torEnabled": "Tor Enabled",
|
||||
"torAddress": "Tor Address",
|
||||
"interfaceModeDesc": "Choose how you want to interact with your node.",
|
||||
"claudeAuthDesc": "Connect your Claude Max account to enable AI chat features.",
|
||||
"connectionStatus": "Connection Status",
|
||||
"notConnected": "Not connected",
|
||||
"reAuthenticate": "Re-authenticate",
|
||||
"loginWithClaude": "Login with Claude",
|
||||
"aiDataAccessDesc": "Control what data the AI assistant can see. All categories are off by default.",
|
||||
"enableAllDesc": "Grant access to all data categories at once",
|
||||
"systemUpdatesDesc": "Check for and install software updates",
|
||||
"webhookNotifications": "Webhook Notifications",
|
||||
"webhookNotificationsDesc": "Get push notifications for critical events via webhook",
|
||||
"enableWebhooks": "Enable webhooks",
|
||||
"disableWebhooks": "Disable webhooks",
|
||||
"webhookUrlLabel": "Webhook URL",
|
||||
"webhookSecretLabel": "Secret (optional, for HMAC-SHA256 signing)",
|
||||
"eventsToNotify": "Events to notify",
|
||||
"containerCrashDesc": "A running container stops unexpectedly",
|
||||
"updateAvailableDesc": "A new system or app update is ready",
|
||||
"diskWarningDesc": "Disk usage exceeds warning threshold",
|
||||
"backupCompleteDesc": "A scheduled or manual backup finishes",
|
||||
"backupRestoreDesc": "Encrypted backups of your identity, settings, and data",
|
||||
"loadingBackups": "Loading backups...",
|
||||
"noBackups": "No backups yet. Create one to protect your node data.",
|
||||
"systemBackup": "System Backup",
|
||||
"createEncryptedBackup": "Create Encrypted Backup",
|
||||
"encryptionPassphrase": "Encryption Passphrase",
|
||||
"enterPassphrase": "Enter a strong passphrase",
|
||||
"descriptionOptional": "Description (optional)",
|
||||
"descriptionPlaceholder": "e.g. Before update",
|
||||
"restoreBackupTitle": "Restore Backup",
|
||||
"restoreWarning": "This will overwrite current node data. Make sure you have the correct passphrase.",
|
||||
"enterBackupPassphrase": "Enter backup passphrase",
|
||||
"networkDesc": "Network connectivity, UPnP, and diagnostics",
|
||||
"webhookSecretPlaceholderFull": "Shared secret for payload signing",
|
||||
"backupCreatedSuccess": "Backup created successfully",
|
||||
"backupCreateFailed": "Failed to create backup",
|
||||
"backupVerifiedOk": "Backup verified — integrity OK",
|
||||
"backupVerifyFailed": "Verification failed: {error}",
|
||||
"backupVerifyRequestFailed": "Verification request failed",
|
||||
"backupRestored": "Backup restored. Restart may be needed.",
|
||||
"backupRestoreFailed": "Restore failed — check passphrase",
|
||||
"backupDeleted": "Backup deleted",
|
||||
"backupDeleteFailed": "Failed to delete backup",
|
||||
"noUsbDrives": "No mounted USB drives found. Insert and mount a USB drive first.",
|
||||
"backupCopiedToUsb": "Backup copied to {path}",
|
||||
"backupUsbFailed": "Failed to copy backup to USB",
|
||||
"deleteBackupConfirm": "Delete this backup permanently?",
|
||||
"verifyPassphrasePrompt": "Enter backup passphrase to verify:",
|
||||
"webhookSaveFailed": "Failed to save webhook configuration",
|
||||
"webhookTestFailed": "Test failed: webhook not sent",
|
||||
"webhookSendFailed": "Failed to send test webhook",
|
||||
"passwordAllFieldsRequired": "All fields are required",
|
||||
"passwordMismatch": "New passwords do not match",
|
||||
"passwordUpdatedSuccess": "Password updated successfully. Use the new password for login and SSH.",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
||||
"passwordNeedLowercase": "Password must contain at least one lowercase letter",
|
||||
"passwordNeedDigit": "Password must contain at least one digit",
|
||||
"passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)",
|
||||
"setupFailed": "Setup failed",
|
||||
"verificationFailed": "Verification failed",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"copyToUsb": "Copy to USB",
|
||||
"diskSpaceWarning": "Disk Space Warning",
|
||||
"modeEasy": "Easy",
|
||||
"modeEasyDesc": "Goal-based interface. Choose what you want to do, and the system handles the rest.",
|
||||
"modePro": "Pro",
|
||||
"modeProDesc": "Full control over all services. Configure everything manually with all technical details.",
|
||||
"modeChat": "Chat",
|
||||
"modeChatDesc": "Conversational AI interface. Manage your node through natural language. Coming soon."
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "App Store",
|
||||
"subtitle": "Discover and install apps for your new sovereign life",
|
||||
"curatedTab": "Curated",
|
||||
"communityTab": "Community",
|
||||
"filterByCategory": "Filter by Category",
|
||||
"searchPlaceholder": "Search apps...",
|
||||
"downloading": "Downloading...",
|
||||
"alreadyInstalled": "Already Installed",
|
||||
"queryingRelays": "Querying Nostr relays for apps...",
|
||||
"noCommunityApps": "No community apps discovered yet.",
|
||||
"noResults": "No apps found in {category} matching \"{query}\"",
|
||||
"noResultsCategory": "No apps found in {category}",
|
||||
"noResultsSearch": "No apps matching \"{query}\"",
|
||||
"all": "All",
|
||||
"community": "Community",
|
||||
"commerce": "Commerce",
|
||||
"money": "Money",
|
||||
"data": "Data",
|
||||
"homeCategory": "Home",
|
||||
"auto": "Auto",
|
||||
"networking": "Networking",
|
||||
"other": "Other",
|
||||
"searchApps": "Search apps",
|
||||
"percentComplete": "{percent}% complete"
|
||||
},
|
||||
"dashboard": {
|
||||
"mainNav": "Main navigation",
|
||||
"mobileNav": "Mobile navigation"
|
||||
},
|
||||
"chat": {
|
||||
"close": "Close",
|
||||
"aiuiConnected": "AIUI connected",
|
||||
"closeAssistant": "Close AI Assistant",
|
||||
"loadingAssistant": "Loading AI assistant...",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"notConfigured": "AI Assistant is not yet configured on this node.",
|
||||
"deployCta": "Deploy the AIUI app from the App Store to enable this feature."
|
||||
},
|
||||
"web5": {
|
||||
"title": "Web5",
|
||||
"subtitle": "Decentralized identity and data protocols",
|
||||
"profitsHelper": "Earn networking profits by hosting decentralized services",
|
||||
"networkingProfits": "Networking Profits",
|
||||
"didStatus": "DID Status",
|
||||
"walletConnection": "Wallet Connection",
|
||||
"wallet": "Wallet",
|
||||
"walletSubtitle": "On-chain, Lightning & Ecash",
|
||||
"nostrRelays": "Nostr Relays",
|
||||
"connectedNodes": "Connected Nodes",
|
||||
"bitcoinDomains": "Bitcoin Domain Names",
|
||||
"domainsSubtitle": "NIP-05 verified identities",
|
||||
"copyDid": "Copy DID",
|
||||
"viewDidDocument": "View DID Document",
|
||||
"createDid": "Create DID",
|
||||
"creatingDid": "Creating...",
|
||||
"manageDomains": "Manage Domains",
|
||||
"relaysConnected": "{count} connected",
|
||||
"peersKnown": "{count} peer(s) known",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Message (over Tor)",
|
||||
"to": "To",
|
||||
"selectPeer": "Select a peer...",
|
||||
"message": "Message",
|
||||
"messagePlaceholder": "Type your message...",
|
||||
"didDocument": "DID Document",
|
||||
"addContent": "Add Content",
|
||||
"addContentTitle": "Add Content",
|
||||
"createIdentity": "Create Identity",
|
||||
"createIdentityTitle": "Create Identity",
|
||||
"deleteIdentity": "Delete Identity",
|
||||
"deleteIdentityTitle": "Delete Identity",
|
||||
"sendBitcoin": "Send Bitcoin",
|
||||
"sendBitcoinTitle": "Send Bitcoin",
|
||||
"receiveBitcoin": "Receive Bitcoin",
|
||||
"receiveBitcoinTitle": "Receive Bitcoin",
|
||||
"domains": "Domains",
|
||||
"domainsTitle": "Domains",
|
||||
"relays": "Relays",
|
||||
"relaysTitle": "Relays",
|
||||
"totalEarned": "Total Earned",
|
||||
"monthlyAvg": "Monthly Avg",
|
||||
"ecashBalance": "Ecash Balance",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
"identityName": "Identity Name",
|
||||
"identityNamePlaceholder": "Enter identity name",
|
||||
"contentTitle": "Title",
|
||||
"contentTitlePlaceholder": "Enter content title",
|
||||
"amount": "Amount",
|
||||
"amountPlaceholder": "Enter amount in sats",
|
||||
"address": "Address",
|
||||
"addressPlaceholder": "Enter Bitcoin address",
|
||||
"deleteIdentityConfirm": "Are you sure you want to delete this identity? This action cannot be undone.",
|
||||
"confirm": "Confirm",
|
||||
"noRelays": "No relays connected",
|
||||
"noDomains": "No domains configured",
|
||||
"addRelay": "Add Relay",
|
||||
"addDomain": "Add Domain",
|
||||
"relayUrl": "Relay URL",
|
||||
"relayUrlPlaceholder": "wss://relay.example.com",
|
||||
"domainName": "Domain Name",
|
||||
"domainNamePlaceholder": "user{'@'}example.com",
|
||||
"peerNodesDescription": "Peer nodes discovered via Nostr. Messages sent over Tor.",
|
||||
"nodeVisibility": "Node Visibility",
|
||||
"nodeVisibilityDesc": "Control how other nodes can discover you",
|
||||
"yourTorAddress": "Your Tor address",
|
||||
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
|
||||
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
|
||||
"noMessages": "No messages yet. Messages from peers will appear here.",
|
||||
"noRequests": "No pending connection requests.",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"discovering": "Discovering...",
|
||||
"discoverNodes": "Discover Nodes on Nostr",
|
||||
"refreshMessages": "Refresh Messages",
|
||||
"refreshRequests": "Refresh Requests",
|
||||
"torServices": "Tor Services",
|
||||
"torServicesDesc": "Hidden services exposing your apps over Tor",
|
||||
"noTorServices": "No Tor hidden services configured.",
|
||||
"content": "Content",
|
||||
"contentDesc": "Share and browse content with peers over Tor",
|
||||
"noSharedContent": "No shared content",
|
||||
"addFilesToShare": "Add files to share with connected peers.",
|
||||
"browse": "Browse",
|
||||
"connectingToPeer": "Connecting to peer over Tor...",
|
||||
"selectPeerToBrowse": "Select a peer to browse",
|
||||
"choosePeerDesc": "Choose a connected peer to see their shared content.",
|
||||
"peerNoContent": "This peer has no shared content.",
|
||||
"identities": "Identities",
|
||||
"identitiesDesc": "Sovereign digital identities (DID:key)",
|
||||
"noIdentities": "No identities yet",
|
||||
"createFirstIdentity": "Create your first sovereign digital identity.",
|
||||
"deleting": "Deleting...",
|
||||
"decentralizedWebNode": "Decentralized Web Node",
|
||||
"dwnDescription": "Personal data store with DID-based access control",
|
||||
"manageDwn": "Manage DWN",
|
||||
"syncing": "Syncing...",
|
||||
"syncNow": "Sync Now",
|
||||
"verifiableCredentials": "Verifiable Credentials",
|
||||
"verifiableCredentialsDesc": "Issue and manage W3C Verifiable Credentials",
|
||||
"noCredentials": "No credentials issued yet",
|
||||
"messageSent": "Message sent over Tor!",
|
||||
"failedToSend": "Failed to send",
|
||||
"pasteInvoice": "Paste a Lightning invoice (BOLT11)",
|
||||
"enterBitcoinAddress": "Enter a Bitcoin address",
|
||||
"sendFailed": "Send failed",
|
||||
"broadcastViaHwWallet": "Broadcast via hardware wallet",
|
||||
"broadcastFailed": "Broadcast failed",
|
||||
"psbtCopied": "PSBT copied!",
|
||||
"enterAmount": "Enter an amount",
|
||||
"pasteEcashToken": "Paste an ecash token",
|
||||
"receiveFailed": "Receive failed",
|
||||
"ecashTokenCopied": "Ecash token copied",
|
||||
"contentAdded": "Content added",
|
||||
"failedToAddContent": "Failed to add content",
|
||||
"contentRemoved": "Content removed",
|
||||
"failedToRemoveContent": "Failed to remove content",
|
||||
"failedToUpdatePricing": "Failed to update pricing",
|
||||
"failedToUpdatePrice": "Failed to update price",
|
||||
"failedToConnectPeer": "Failed to connect to peer",
|
||||
"onionAddressCopied": "Onion address copied",
|
||||
"streamUrlCopied": "Stream URL copied",
|
||||
"playerError": "Unable to load media. The content may only be accessible over Tor.",
|
||||
"connectionAccepted": "Connection accepted",
|
||||
"failedToAcceptRequest": "Failed to accept request",
|
||||
"requestRejected": "Request rejected",
|
||||
"failedToRejectRequest": "Failed to reject request",
|
||||
"visibilitySetTo": "Visibility set to {level}",
|
||||
"failedToUpdateVisibility": "Failed to update visibility",
|
||||
"didCopied": "DID copied to clipboard",
|
||||
"defaultIdentityUpdated": "Default identity updated",
|
||||
"failedToSetDefault": "Failed to set default",
|
||||
"identityCreated": "Identity created",
|
||||
"failedToCreateIdentity": "Failed to create identity",
|
||||
"identityDeleted": "Identity deleted",
|
||||
"failedToDeleteIdentity": "Failed to delete identity",
|
||||
"registrationFailed": "Registration failed",
|
||||
"removeFailed": "Remove failed",
|
||||
"failedToAddRelay": "Failed to add relay",
|
||||
"failedToRemoveRelay": "Failed to remove relay",
|
||||
"failedToToggleRelay": "Failed to toggle relay",
|
||||
"downloadUrlCopied": "Download URL copied",
|
||||
"hardwareWalletDetected": "Hardware Wallet Detected",
|
||||
"namesRegistered": "Names Registered",
|
||||
"expiringSoon": "Expiring Soon",
|
||||
"nostrRelaysDesc": "Decentralized social networking relays",
|
||||
"relaysConnectedLabel": "Relays Connected",
|
||||
"totalRelays": "Total Relays",
|
||||
"freeAccessDesc": "Available to all peers for free",
|
||||
"peersOnlyAccessDesc": "Available only to connected peers",
|
||||
"signWithHwWallet": "Sign with Hardware Wallet",
|
||||
"createsPsbt": "Creates a PSBT for external signing",
|
||||
"generateFreshAddress": "Generate a fresh Bitcoin address",
|
||||
"registerNewName": "Register New Name",
|
||||
"verifyNip05": "Verify NIP-05",
|
||||
"peers": "Peers",
|
||||
"messages": "Messages",
|
||||
"requests": "Requests",
|
||||
"myContent": "My Content",
|
||||
"browsePeers": "Browse Peers",
|
||||
"verified": "Verified",
|
||||
"invalid": "Invalid",
|
||||
"stream": "Stream",
|
||||
"download": "Download"
|
||||
},
|
||||
"appDetails": {
|
||||
"backToApps": "Back to My Apps",
|
||||
"backToStore": "Back to App Store",
|
||||
"screenshots": "Screenshots",
|
||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||
"about": "About {name}",
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"ram": "RAM",
|
||||
"ramDesc": "Minimum 512MB",
|
||||
"storage": "Storage",
|
||||
"storageDesc": "~100MB",
|
||||
"links": "Links",
|
||||
"website": "Website",
|
||||
"sourceCode": "Source Code",
|
||||
"documentation": "Documentation",
|
||||
"services": "Services",
|
||||
"guardian": "Guardian",
|
||||
"gateway": "Gateway",
|
||||
"access": "Access",
|
||||
"lan": "LAN",
|
||||
"tor": "Tor",
|
||||
"requiresTor": "Requires Tor Browser",
|
||||
"channels": "Channels",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"notFoundTitle": "App Not Found",
|
||||
"notFoundMessage": "The requested application could not be found",
|
||||
"installed": "Installed",
|
||||
"channels": "Channels"
|
||||
},
|
||||
"containerDetails": {
|
||||
"back": "Back",
|
||||
"subtitle": "Container details and management",
|
||||
"containerInfo": "Container Information",
|
||||
"actions": "Actions",
|
||||
"logs": "Logs",
|
||||
"containerId": "Container ID",
|
||||
"image": "Image",
|
||||
"state": "State",
|
||||
"created": "Created",
|
||||
"startContainer": "Start Container",
|
||||
"stopContainer": "Stop Container",
|
||||
"loadingLogs": "Loading logs...",
|
||||
"noLogs": "No logs available"
|
||||
},
|
||||
"marketplaceDetails": {
|
||||
"backToStore": "Back to App Store",
|
||||
"screenshots": "Screenshots",
|
||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||
"about": "About {name}",
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"noRequirements": "No additional dependencies required",
|
||||
"installRequirements": "Install Requirements",
|
||||
"links": "Links",
|
||||
"downloadPackage": "Download Package",
|
||||
"installed": "Installed",
|
||||
"notInstalled": "Not Installed",
|
||||
"open": "Open",
|
||||
"loadingDetails": "Loading app details...",
|
||||
"notFoundTitle": "App Not Found",
|
||||
"notFoundMessage": "The requested application could not be found in the marketplace",
|
||||
"installFailed": "Installation Failed",
|
||||
"depRunning": "Running",
|
||||
"depStopped": "Installed but stopped",
|
||||
"depNotInstalled": "Not installed"
|
||||
},
|
||||
"goalDetail": {
|
||||
"backToGoals": "Back to Goals",
|
||||
"notFound": "Goal not found.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"notStarted": "Not Started",
|
||||
"inProgress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"syncTitle": "Sovereignty takes a little patience",
|
||||
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
|
||||
"installApp": "Install {name}",
|
||||
"openAndConfigure": "Open & Configure",
|
||||
"iveDoneThis": "I've Done This",
|
||||
"complete": "Complete",
|
||||
"allSet": "All Set!",
|
||||
"goalReady": "{title} is ready to go.",
|
||||
"viewMyServices": "View My Services"
|
||||
},
|
||||
"monitoring": {
|
||||
"title": "Monitoring",
|
||||
"subtitle": "Real-time system metrics and container resource usage",
|
||||
"cpuUsage": "CPU Usage (%)",
|
||||
"memoryUsage": "Memory Usage (%)",
|
||||
"networkIo": "Network I/O (bytes)",
|
||||
"rpcLatency": "RPC Latency (ms)",
|
||||
"alertHistory": "Alert History",
|
||||
"hideConfig": "Hide Config",
|
||||
"noAlerts": "No alerts fired",
|
||||
"containerResources": "Container Resources",
|
||||
"noContainerMetrics": "No container metrics available",
|
||||
"systemHealth": "System Health",
|
||||
"load": "Load:",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportJson": "Export JSON",
|
||||
"diskUsage": "Disk Usage",
|
||||
"ramUsage": "RAM Usage",
|
||||
"containerCrash": "Container Crash",
|
||||
"rpcLatencySpike": "RPC Latency Spike",
|
||||
"sslCertExpiry": "SSL Cert Expiry",
|
||||
"refreshFooter": "Refreshing every 5 seconds",
|
||||
"wsConnections": "WS connections: {count}",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"network": "Network"
|
||||
},
|
||||
"systemUpdate": {
|
||||
"title": "System Update",
|
||||
"subtitle": "Manage software updates for your Archipelago node",
|
||||
"currentSystem": "Current System",
|
||||
"updateAvailable": "Update Available",
|
||||
"upToDate": "System is up to date",
|
||||
"downloading": "Downloading Update...",
|
||||
"applying": "Applying Update...",
|
||||
"updateSchedule": "Update Schedule",
|
||||
"actions": "Actions",
|
||||
"lastChecked": "Last Checked",
|
||||
"new": "New",
|
||||
"changelog": "Changelog",
|
||||
"componentsToUpdate": "{count} component(s) to update",
|
||||
"manualOnly": "Manual Only",
|
||||
"manualOnlyDesc": "Never check automatically. You control when to check and install updates.",
|
||||
"dailyCheck": "Daily Check",
|
||||
"dailyCheckDesc": "Check for updates once per day. You decide when to install.",
|
||||
"autoApply": "Auto-Apply",
|
||||
"autoApplyDesc": "Check daily and automatically install updates at 3 AM. Service restarts as needed.",
|
||||
"downloadUpdate": "Download Update",
|
||||
"applyUpdate": "Apply Update",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking...",
|
||||
"rollback": "Rollback to Previous",
|
||||
"backToSettings": "Back to Settings",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"applyWarning": "Installing components and restarting services. Do not power off.",
|
||||
"applyTitle": "Apply Update?",
|
||||
"applyMessage": "The backend service will restart. This may take a moment.",
|
||||
"rollbackTitle": "Rollback Version?",
|
||||
"rollbackMessage": "This will restore the previous version. The backend service will restart.",
|
||||
"applyNow": "Apply Now",
|
||||
"rollbackButton": "Rollback",
|
||||
"upToDateMessage": "Your system is up to date. No updates available. Your system is running the latest version.",
|
||||
"checkFailed": "Failed to check for updates. Check your internet connection.",
|
||||
"downloadSuccess": "Downloaded {count} component(s) ({size}MB)",
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"applySuccess": "Update applied. The service will restart momentarily.",
|
||||
"applyFailed": "Failed to apply update. You can try again or rollback.",
|
||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||
"rollbackFailed": "Rollback failed."
|
||||
},
|
||||
"kioskRecovery": {
|
||||
"title": "Archipelago Recovery",
|
||||
"subtitle": "Kiosk failsafe — no authentication required",
|
||||
"serverAddress": "Server Address",
|
||||
"webUi": "Web UI: http://{address}",
|
||||
"scanForMobile": "Scan for mobile access",
|
||||
"backend": "Backend",
|
||||
"unreachable": "Unreachable",
|
||||
"containers": "Containers",
|
||||
"goToLogin": "Go to Login",
|
||||
"lastChecked": "Last checked: {time}"
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Channels
|
||||
{{ t('appDetails.channels') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@@ -70,7 +70,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Launch
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@@ -80,7 +80,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Start
|
||||
{{ t('common.start') }}
|
||||
</button>
|
||||
<button
|
||||
@click="restartApp"
|
||||
@@ -89,7 +89,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Restart
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@@ -100,7 +100,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
{{ t('common.stop') }}
|
||||
</button>
|
||||
<button
|
||||
@click="uninstallApp"
|
||||
@@ -109,7 +109,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Uninstall
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@
|
||||
<button
|
||||
@click="uninstallApp"
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
||||
title="Uninstall"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -164,7 +164,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Launch
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@@ -174,7 +174,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Start
|
||||
{{ t('common.start') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@@ -185,7 +185,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
{{ t('common.stop') }}
|
||||
</button>
|
||||
<button
|
||||
@click="restartApp"
|
||||
@@ -195,7 +195,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Restart
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
@@ -218,12 +218,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">About {{ pkg.manifest.title }}</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
@@ -231,7 +231,7 @@
|
||||
|
||||
<!-- Features (if available) -->
|
||||
<div v-if="features.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
@@ -251,26 +251,26 @@
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Version</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Developer</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Status</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">License</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">Category</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium">App</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,19 +278,19 @@
|
||||
|
||||
<!-- Fedimint Services Card -->
|
||||
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Services</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 py-2 border-b border-white/10">
|
||||
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">Guardian</p>
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">Gateway</p>
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,14 +299,14 @@
|
||||
|
||||
<!-- Access (LAN + Tor) Card -->
|
||||
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">LAN</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
|
||||
<a
|
||||
:href="lanUrl"
|
||||
target="_blank"
|
||||
@@ -322,9 +322,9 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">Tor</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
|
||||
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||
<p class="text-white/50 text-xs mt-1">Requires Tor Browser</p>
|
||||
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,15 +332,15 @@
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">RAM</p>
|
||||
<p class="text-white/60 text-sm">Minimum 512MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -348,8 +348,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">Storage</p>
|
||||
<p class="text-white/60 text-sm">~100MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,7 +357,7 @@
|
||||
|
||||
<!-- Links Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="pkg.manifest.website"
|
||||
@@ -369,7 +369,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
{{ t('appDetails.website') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
@@ -378,7 +378,7 @@
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Source Code
|
||||
{{ t('appDetails.sourceCode') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
@@ -387,7 +387,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Documentation
|
||||
{{ t('appDetails.documentation') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,8 +400,8 @@
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
|
||||
<p class="text-white/70">The requested application could not be found</p>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall Confirmation Modal -->
|
||||
@@ -424,10 +424,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
||||
This will remove the app and stop its container.
|
||||
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,13 +436,13 @@
|
||||
@click="closeUninstallModal()"
|
||||
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -455,6 +454,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { PackageState } from '../types/api'
|
||||
@@ -464,6 +464,7 @@ import { dummyApps } from '../utils/dummyApps'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
@@ -594,11 +595,11 @@ useModalKeyboard(
|
||||
const backButtonText = computed(() => {
|
||||
// Check if we came from marketplace via query parameter
|
||||
if (route.query.from === 'marketplace') {
|
||||
return 'Back to App Store'
|
||||
return t('appDetails.backToStore')
|
||||
}
|
||||
|
||||
|
||||
// Default to My Apps
|
||||
return 'Back to My Apps'
|
||||
return t('appDetails.backToApps')
|
||||
})
|
||||
|
||||
// Check if app has a UI interface and is running
|
||||
@@ -827,7 +828,7 @@ async function confirmUninstall() {
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||
alert('Failed to uninstall app')
|
||||
alert(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="hidden md:block mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
|
||||
<p class="text-white/70">Manage your installed applications</p>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
@@ -10,8 +10,8 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search installed apps..."
|
||||
aria-label="Search installed apps"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -22,20 +22,20 @@
|
||||
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">No Apps Installed</h3>
|
||||
<p class="text-white/70 mb-6">Get started by browsing the app store</p>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
|
||||
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
Browse App Store
|
||||
{{ t('apps.browseAppStore') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
|
||||
<p class="text-white/70">No apps matching "{{ searchQuery }}"</p>
|
||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid (alphabetically by title, stable across run state) -->
|
||||
@@ -56,8 +56,8 @@
|
||||
<button
|
||||
@click.stop="showUninstallModal(id as string, pkg)"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||
:aria-label="`Uninstall ${pkg.manifest?.title || id}`"
|
||||
title="Uninstall"
|
||||
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@@ -100,7 +100,7 @@
|
||||
@click.stop="launchApp(id as string)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>
|
||||
Launch
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||
@@ -119,7 +119,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<span>{{ loadingActions[id as string] ? 'Starting...' : 'Start' }}</span>
|
||||
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running' || pkg.state === 'starting'"
|
||||
@@ -138,7 +138,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<span>{{ loadingActions[id as string] ? 'Stopping...' : 'Stop' }}</span>
|
||||
<span>{{ loadingActions[id as string] ? t('common.stopping') : t('common.stop') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,10 +167,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
|
||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70">
|
||||
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
||||
This will remove the app and stop its container.
|
||||
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,13 +179,13 @@
|
||||
@click="closeUninstallModal()"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +197,7 @@
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" aria-label="Dismiss error" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -208,7 +207,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { PackageState, type PackageDataEntry } from '../types/api'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" aria-label="Close AI Assistant" @click="closeChat">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Close</span>
|
||||
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="aiuiConnected"
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
title="AIUI connected"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile back button -->
|
||||
<button class="chat-mobile-back md:hidden" aria-label="Go back" @click="closeChat">
|
||||
<button class="chat-mobile-back md:hidden" :aria-label="t('common.goBack')" @click="closeChat">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
@@ -27,7 +27,7 @@
|
||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">Loading AI assistant...</p>
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -37,7 +37,7 @@
|
||||
v-if="aiuiUrl"
|
||||
ref="aiuiFrame"
|
||||
:src="aiuiUrl"
|
||||
title="AI Assistant"
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
allow="microphone"
|
||||
@@ -52,12 +52,12 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
|
||||
<p class="text-white/60 mb-4 leading-relaxed">
|
||||
AI Assistant is not yet configured on this node.
|
||||
{{ t('chat.notConfigured') }}
|
||||
</p>
|
||||
<p class="text-xs text-white/30">
|
||||
Deploy the AIUI app from the App Store to enable this feature.
|
||||
{{ t('chat.deployCta') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,8 +68,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
{{ t('containerDetails.back') }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||||
<p class="text-white/70">Container details and management</p>
|
||||
<p class="text-white/70">{{ t('containerDetails.subtitle') }}</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
v-if="container"
|
||||
:state="container.state as any"
|
||||
:health="healthStatus as any"
|
||||
:state="container.state as ContainerStateValue"
|
||||
:health="healthStatus as HealthStatusValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,22 +41,22 @@
|
||||
<div v-else-if="container" key="content" class="space-y-6">
|
||||
<!-- Container Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.containerInfo') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Container ID</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.containerId') }}</span>
|
||||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Image</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.image') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">State</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.state') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Created</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.created') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.actions') }}</h2>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@@ -72,7 +72,7 @@
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Start Container
|
||||
{{ t('containerDetails.startContainer') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -80,21 +80,21 @@
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Stop Container
|
||||
{{ t('containerDetails.stopContainer') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="actionLoading || container.state !== 'running'"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Restart
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleRemove"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
{{ t('common.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,21 +102,21 @@
|
||||
<!-- Logs Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-white">Logs</h2>
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('containerDetails.logs') }}</h2>
|
||||
<button
|
||||
@click="refreshLogs"
|
||||
:disabled="logsLoading"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||||
Loading logs...
|
||||
{{ t('containerDetails.loadingLogs') }}
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||||
No logs available
|
||||
{{ t('containerDetails.noLogs') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
@@ -133,12 +133,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
type ContainerStateValue = 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
type HealthStatusValue = 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContainerStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
const appName = computed(() => {
|
||||
@@ -148,7 +154,7 @@ const appName = computed(() => {
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const container = ref<any>(null)
|
||||
const container = ref<ContainerStatusData | null>(null)
|
||||
const logs = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
@@ -169,7 +175,7 @@ async function loadContainer() {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -180,7 +186,7 @@ async function loadLogs() {
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
console.error('Failed to load logs:', e)
|
||||
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
@@ -202,7 +208,7 @@ async function handleStart() {
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
@@ -214,7 +220,7 @@ async function handleStop() {
|
||||
await store.stopContainer(appId.value)
|
||||
await loadContainer()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
@@ -229,14 +235,14 @@ async function handleRestart() {
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to restart container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(`Are you sure you want to remove ${appName.value}? This will delete the container and all its data.`)) {
|
||||
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -245,7 +251,7 @@ async function handleRemove() {
|
||||
await store.removeContainer(appId.value)
|
||||
router.push('/dashboard/apps')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||
<!-- Skip to main content link for keyboard users -->
|
||||
<a href="#main-content" class="skip-to-content">Skip to main content</a>
|
||||
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a>
|
||||
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" aria-label="Main navigation">
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" :aria-label="t('dashboard.mainNav')">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
@@ -296,7 +296,7 @@
|
||||
<nav
|
||||
ref="mobileTabBar"
|
||||
data-mobile-tab-bar
|
||||
aria-label="Mobile navigation"
|
||||
:aria-label="t('dashboard.mobileNav')"
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: env(safe-area-inset-bottom, 0px);"
|
||||
@@ -381,7 +381,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Goals</span>
|
||||
<span>{{ t('goalDetail.backToGoals') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Goal not found -->
|
||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
||||
<p class="text-white/70">Goal not found.</p>
|
||||
<p class="text-white/70">{{ t('goalDetail.notFound') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Goal wizard -->
|
||||
@@ -23,7 +23,7 @@
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/60">Step {{ currentStepDisplay }} of {{ goal.steps.length }}</span>
|
||||
<span class="text-sm text-white/60">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: goal.steps.length }) }}</span>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@@ -40,10 +40,9 @@
|
||||
v-if="showSyncMessage"
|
||||
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Sovereignty takes a little patience</h3>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ t('goalDetail.syncTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm leading-relaxed">
|
||||
Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else.
|
||||
This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.
|
||||
{{ t('goalDetail.syncMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -94,28 +93,28 @@
|
||||
:disabled="isInstalling"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ isInstalling ? 'Installing...' : `Install ${step.title.replace('Install ', '')}` }}
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Open & Configure
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
I've Done This
|
||||
{{ t('goalDetail.iveDoneThis') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
|
||||
>
|
||||
Complete
|
||||
{{ t('goalDetail.complete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,10 +130,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">All Set!</h2>
|
||||
<p class="text-white/60 mb-6">{{ goal.title }} is ready to go.</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
|
||||
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
|
||||
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
View My Services
|
||||
{{ t('goalDetail.viewMyServices') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -144,11 +143,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
@@ -193,9 +194,9 @@ const progressPercent = computed(() => {
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (overallStatus.value === 'completed') return 'Completed'
|
||||
if (overallStatus.value === 'in-progress') return 'In Progress'
|
||||
return 'Not Started'
|
||||
if (overallStatus.value === 'completed') return t('goalDetail.completed')
|
||||
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
|
||||
return t('goalDetail.notStarted')
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
@@ -251,7 +252,7 @@ async function installApp(step: GoalStep) {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
} catch (err) {
|
||||
console.error('[GoalDetail] Install failed:', err)
|
||||
if (import.meta.env.DEV) console.error('[GoalDetail] Install failed:', err)
|
||||
} finally {
|
||||
isInstalling.value = false
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">Setup</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Update Available: v{{ updateVersion }}</p>
|
||||
<p class="text-sm font-medium text-white">{{ t('home.updateAvailable', { version: updateVersion }) }}</p>
|
||||
<p v-if="updateChangelog" class="text-xs text-white/60 truncate">{{ updateChangelog }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<RouterLink to="/dashboard/settings/update" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Update Now
|
||||
{{ t('home.updateNow') }}
|
||||
</RouterLink>
|
||||
<button @click="dismissUpdate" aria-label="Dismiss update notification" class="text-white/40 hover:text-white/80 transition-colors p-1" title="Dismiss">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -56,8 +56,8 @@
|
||||
role="tablist"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">Setup</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Setup tab: goal-based cards -->
|
||||
@@ -85,10 +85,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.myApps') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.myAppsDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/apps" aria-label="Go to My Apps" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/apps" :aria-label="t('home.goToApps')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -96,20 +96,20 @@
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.installed') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Running</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Browse Store
|
||||
{{ t('home.browseStore') }}
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/apps" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Apps
|
||||
{{ t('home.manageApps') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,10 +128,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.cloud') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.cloudDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/cloud" aria-label="Go to Cloud" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/cloud" :aria-label="t('home.goToCloud')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -139,20 +139,20 @@
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.storageUsed') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Folders</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.folders') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ cloudFolderDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
View Folders
|
||||
{{ t('home.viewFolders') }}
|
||||
</RouterLink>
|
||||
<button @click="uploadFiles" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Upload Files
|
||||
{{ t('home.uploadFiles') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,10 +171,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.network') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.networkDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/server" aria-label="Go to Network" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/server" :aria-label="t('home.goToNetwork')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -184,28 +184,28 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="servicesDotColor"></div>
|
||||
<span class="text-sm text-white/80">Services Status</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.servicesStatus') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="servicesStatusColor">{{ servicesStatusText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="connectivityDotColor"></div>
|
||||
<span class="text-sm text-white/80">Connectivity</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.connectivity') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="connectivityColor">{{ connectivityText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span class="text-sm text-white/80">Running Apps</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.runningApps') }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-white/80 font-medium">{{ runningCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Network
|
||||
{{ t('home.manageNetwork') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,10 +224,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.web5') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.web5Desc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/web5" aria-label="Go to Web5" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -237,28 +237,28 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="web5DidStatus === 'Active' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm text-white/80">DID Status</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.didStatus') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="web5DidStatus === 'Active' ? 'text-green-400' : 'text-white/50'">{{ web5DidStatus }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="web5DwnStatus === 'Synced' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm text-white/80">DWN Sync</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.dwnSync') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="web5DwnStatus === 'Synced' ? 'text-green-400' : 'text-white/50'">{{ web5DwnStatus }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/30"></div>
|
||||
<span class="text-sm text-white/80">Credentials</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.credentials') }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-white/50 font-medium">{{ web5CredentialCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Web5
|
||||
{{ t('home.manageWeb5') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,10 +276,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">System</h2>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ systemUptimeDisplay }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/server" aria-label="Go to System" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/server" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -298,7 +298,7 @@
|
||||
<template v-else>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">CPU</p>
|
||||
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ systemStats.cpuPercent.toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@@ -307,7 +307,7 @@
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">RAM</p>
|
||||
<p class="text-xs text-white/60">{{ t('home.ram') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.memPercent)">{{ formatBytes(systemStats.memUsed) }} / {{ formatBytes(systemStats.memTotal) }}</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@@ -316,7 +316,7 @@
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">Disk</p>
|
||||
<p class="text-xs text-white/60">{{ t('home.disk') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.diskPercent)">{{ formatBytes(systemStats.diskUsed) }} / {{ formatBytes(systemStats.diskTotal) }}</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@@ -341,8 +341,8 @@
|
||||
<div class="home-card-inner px-6 py-6">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Quick Start Goals</h2>
|
||||
<p class="text-sm text-white/60 mb-4">Not sure where to start? Try a guided setup.</p>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="dismissQuickStart"
|
||||
@@ -374,7 +374,7 @@
|
||||
<!-- Chat Mode: redirect to Chat view -->
|
||||
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">
|
||||
Open AI Assistant
|
||||
{{ t('home.openAI') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,7 +383,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
@@ -407,8 +410,8 @@ const QUICK_START_RESHOW_LOGINS = 5
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const LINE1 = "Welcome Noderunner"
|
||||
const LINE2 = "Here's an overview of your sovereign life"
|
||||
const LINE1 = t('home.title')
|
||||
const LINE2 = t('home.subtitle')
|
||||
const MS_PER_CHAR = 55
|
||||
|
||||
const displayLine1 = ref('')
|
||||
@@ -481,8 +484,8 @@ const servicesAllRunning = computed(() =>
|
||||
appCount.value > 0 && runningCount.value === appCount.value
|
||||
)
|
||||
const servicesStatusText = computed(() => {
|
||||
if (appCount.value === 0) return 'No Apps'
|
||||
return servicesAllRunning.value ? 'All Running' : `${runningCount.value}/${appCount.value} Running`
|
||||
if (appCount.value === 0) return t('home.noApps')
|
||||
return servicesAllRunning.value ? t('home.allRunning') : `${runningCount.value}/${appCount.value} ${t('home.runningLabel')}`
|
||||
})
|
||||
const servicesStatusColor = computed(() =>
|
||||
appCount.value === 0 ? 'text-white/60' : servicesAllRunning.value ? 'text-green-400' : 'text-yellow-400'
|
||||
@@ -490,7 +493,7 @@ const servicesStatusColor = computed(() =>
|
||||
const servicesDotColor = computed(() =>
|
||||
appCount.value === 0 ? 'bg-white/40' : servicesAllRunning.value ? 'bg-green-400' : 'bg-yellow-400'
|
||||
)
|
||||
const connectivityText = computed(() => store.isConnected ? 'Connected' : 'Disconnected')
|
||||
const connectivityText = computed(() => store.isConnected ? t('common.connected') : t('common.disconnected'))
|
||||
const connectivityColor = computed(() => store.isConnected ? 'text-green-400' : 'text-red-400')
|
||||
const connectivityDotColor = computed(() => store.isConnected ? 'bg-green-400' : 'bg-red-400')
|
||||
|
||||
@@ -648,7 +651,7 @@ const systemStats = reactive({
|
||||
})
|
||||
|
||||
const systemUptimeDisplay = computed(() => {
|
||||
if (systemStats.uptimeSecs === 0) return 'System monitoring'
|
||||
if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring')
|
||||
const days = Math.floor(systemStats.uptimeSecs / 86400)
|
||||
const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600)
|
||||
if (days > 0) return `Uptime: ${days}d ${hours}h`
|
||||
|
||||
131
neode-ui/src/views/KioskRecovery.vue
Normal file
131
neode-ui/src/views/KioskRecovery.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black flex items-center justify-center p-6">
|
||||
<div class="glass-card p-8 w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ t('kioskRecovery.title') }}</h1>
|
||||
<p class="text-sm text-white/50">{{ t('kioskRecovery.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Server IP -->
|
||||
<div class="bg-white/5 rounded-lg p-4 mb-4">
|
||||
<div class="text-xs text-white/50 mb-1">{{ t('kioskRecovery.serverAddress') }}</div>
|
||||
<div class="text-lg font-mono text-white font-medium">{{ serverIp || t('common.loading') }}</div>
|
||||
<div v-if="serverIp" class="text-xs text-white/40 mt-1">{{ t('kioskRecovery.webUi', { address: serverIp }) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="serverIp" class="bg-white/5 rounded-lg p-4 mb-4 flex flex-col items-center">
|
||||
<div class="text-xs text-white/50 mb-2">{{ t('kioskRecovery.scanForMobile') }}</div>
|
||||
<div class="bg-white p-3 rounded-lg inline-block">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="w-32 h-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.backend') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="backendHealthy ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<span class="text-sm" :class="backendHealthy ? 'text-green-400' : 'text-red-400'">
|
||||
{{ backendHealthy ? t('common.healthy') : t('kioskRecovery.unreachable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.containers') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ containerCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('monitoring.diskUsage') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ diskUsage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button @click="goToLogin" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30">
|
||||
{{ t('kioskRecovery.goToLogin') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-xs text-white/30">{{ t('kioskRecovery.lastChecked', { time: lastChecked }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const serverIp = ref('')
|
||||
const backendHealthy = ref(false)
|
||||
const containerCount = ref('—')
|
||||
const diskUsage = ref('—')
|
||||
const lastChecked = ref('—')
|
||||
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (!serverIp.value) return ''
|
||||
const url = `http://${serverIp.value}`
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=128x128&data=${encodeURIComponent(url)}`
|
||||
})
|
||||
|
||||
async function refreshDiagnostics() {
|
||||
lastChecked.value = new Date().toLocaleTimeString()
|
||||
|
||||
// Detect server IP from window location
|
||||
serverIp.value = window.location.hostname !== 'localhost'
|
||||
? window.location.hostname
|
||||
: '127.0.0.1'
|
||||
|
||||
// Check backend health
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(5000) })
|
||||
backendHealthy.value = res.ok
|
||||
} catch {
|
||||
backendHealthy.value = false
|
||||
}
|
||||
|
||||
// Get system stats (unauthenticated won't work for RPC, but try health)
|
||||
if (backendHealthy.value) {
|
||||
try {
|
||||
const statsRes = await fetch('/rpc/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'system.stats' }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await statsRes.json()
|
||||
if (data.result) {
|
||||
const disk = data.result.disk
|
||||
if (disk) {
|
||||
const usedPct = ((disk.used / disk.total) * 100).toFixed(0)
|
||||
diskUsage.value = `${usedPct}% used`
|
||||
}
|
||||
containerCount.value = String(data.result.containers?.running ?? '—')
|
||||
}
|
||||
} catch {
|
||||
// Stats require auth — show defaults
|
||||
containerCount.value = '—'
|
||||
diskUsage.value = '—'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshDiagnostics()
|
||||
})
|
||||
</script>
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
<span v-if="isSetupMode && !isSetup">Set Up Your Node</span>
|
||||
<span v-else>Welcome to Archipelago</span>
|
||||
<span v-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
|
||||
<span v-else>{{ t('login.title') }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Server Startup Progress -->
|
||||
@@ -26,7 +26,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-white/60">Server starting up...</span>
|
||||
<span class="text-sm text-white/60">{{ t('login.serverStarting') }}</span>
|
||||
</div>
|
||||
<div class="startup-progress-track">
|
||||
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
|
||||
@@ -47,14 +47,14 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="setup-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Password
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter a password (min 8 characters)"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keyup.enter="handleSetupWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
@@ -62,14 +62,14 @@
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="setup-confirm-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Confirm Password
|
||||
{{ t('login.confirmPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Confirm your password"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keyup.enter="handleSetupWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
@@ -80,13 +80,13 @@
|
||||
:disabled="loading || formDisabled || !password || password.length < 8 || password !== confirmPassword"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Set Up Node</span>
|
||||
<span v-if="!loading">{{ t('login.setupButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" 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"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Setting up...
|
||||
{{ t('login.settingUp') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -97,8 +97,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p class="text-white/80 text-sm mb-1">Two-Factor Authentication</p>
|
||||
<p class="text-white/50 text-xs">Enter the 6-digit code from your authenticator app</p>
|
||||
<p class="text-white/80 text-sm mb-1">{{ t('login.twoFactorTitle') }}</p>
|
||||
<p class="text-white/50 text-xs">{{ t('login.totpInstruction') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
@@ -110,7 +110,7 @@
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
aria-label="Two-factor authentication code"
|
||||
:aria-label="t('login.totpLabel')"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@keyup.enter="handleTotpVerify"
|
||||
@@ -123,13 +123,13 @@
|
||||
:disabled="loading || !totpCode"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
|
||||
>
|
||||
<span v-if="!loading">Verify</span>
|
||||
<span v-if="!loading">{{ t('login.verifyButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" 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"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
{{ t('login.verifying') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
@click="useBackupCode = !useBackupCode; totpCode = ''"
|
||||
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
|
||||
>
|
||||
{{ useBackupCode ? 'Use authenticator code' : 'Use a backup code instead' }}
|
||||
{{ useBackupCode ? t('login.useAuthCode') : t('login.useBackupCode') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -145,14 +145,14 @@
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Password
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keyup.enter="handleLoginWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
@@ -163,20 +163,20 @@
|
||||
:disabled="loading || formDisabled || !password"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Login</span>
|
||||
<span v-if="!loading">{{ t('login.loginButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" 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"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Logging in...
|
||||
{{ t('login.loggingIn') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="mt-6 text-center text-sm text-white/40">
|
||||
Password recovery requires SSH access to the server.
|
||||
{{ t('login.recoveryNote') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
@click="replayIntro"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
Replay Intro
|
||||
{{ t('login.replayIntro') }}
|
||||
</button>
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@@ -194,7 +194,7 @@
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isResettingOnboarding ? 'Resetting...' : 'Onboarding' }}
|
||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,8 +204,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
@@ -350,12 +353,12 @@ function handleSetupWithSound() {
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password.value || password.value.length < 8) {
|
||||
error.value = 'Password must be at least 8 characters'
|
||||
error.value = t('login.errorMinLength')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match'
|
||||
error.value = t('login.errorMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -381,9 +384,9 @@ async function handleSetup() {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = 'Server is starting up. Please try again in a moment.'
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || 'Setup failed. Please try again.'
|
||||
error.value = msg || t('login.errorSetupFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
@@ -425,9 +428,9 @@ async function handleLogin() {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = 'Server is starting up. Please try again in a moment.'
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || 'Login failed. Please check your password.'
|
||||
error.value = msg || t('login.errorLoginFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
@@ -464,7 +467,7 @@ async function handleTotpVerify() {
|
||||
totpCode.value = ''
|
||||
error.value = msg
|
||||
} else {
|
||||
error.value = msg || 'Invalid code. Please try again.'
|
||||
error.value = msg || t('login.errorInvalidCode')
|
||||
}
|
||||
totpCode.value = ''
|
||||
} finally {
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
|
||||
<div class="hidden md:flex mb-8 items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-white mb-2">App Store</h1>
|
||||
<p class="text-white/70">Discover and install apps for your new sovereign life</p>
|
||||
<h1 class="text-4xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@
|
||||
: 'text-white/60 hover:text-white/80 border border-transparent'
|
||||
]"
|
||||
>
|
||||
Curated
|
||||
{{ t('marketplace.curatedTab') }}
|
||||
</button>
|
||||
<button
|
||||
@click="marketplaceSource = 'community'; loadNostrMarketplace()"
|
||||
@@ -108,7 +108,7 @@
|
||||
: 'text-white/60 hover:text-white/80 border border-transparent'
|
||||
]"
|
||||
>
|
||||
Community
|
||||
{{ t('marketplace.communityTab') }}
|
||||
<span v-if="nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">{{ nostrApps.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -133,8 +133,8 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -159,8 +159,8 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -227,7 +227,7 @@
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium cursor-not-allowed"
|
||||
>
|
||||
Already Installed
|
||||
{{ t('marketplace.alreadyInstalled') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="app.source === 'local' || app.dockerImage"
|
||||
@@ -241,16 +241,16 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
{{ installingApps.get(app.id)?.message || 'Installing...' }}
|
||||
{{ installingApps.get(app.id)?.message || t('common.installing') }}
|
||||
</span>
|
||||
<span v-else>Install</span>
|
||||
<span v-else>{{ t('common.install') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>
|
||||
Not Available
|
||||
{{ t('common.notAvailable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,14 +263,14 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">{{ marketplaceSource === 'community' ? 'Querying Nostr relays for apps...' : 'Loading apps...' }}</p>
|
||||
<p class="text-white/70">{{ marketplaceSource === 'community' ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
|
||||
</div>
|
||||
<div v-else-if="nostrError && marketplaceSource === 'community'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">No community apps discovered yet.</p>
|
||||
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
||||
</div>
|
||||
<p v-else class="text-white/70">No apps found in {{ categories.find(c => c.id === selectedCategory)?.name }}{{ searchQuery ? ` matching "${searchQuery}"` : '' }}</p>
|
||||
<p v-else class="text-white/70">{{ searchQuery && selectedCategory !== 'all' ? t('marketplace.noResults', { category: categories.find(c => c.id === selectedCategory)?.name, query: searchQuery }) : searchQuery ? t('marketplace.noResultsSearch', { query: searchQuery }) : t('marketplace.noResultsCategory', { category: categories.find(c => c.id === selectedCategory)?.name }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
@@ -298,7 +298,7 @@
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
|
||||
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
||||
<button
|
||||
@click="closeFilterModal()"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
@@ -372,6 +372,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
|
||||
@@ -381,22 +382,23 @@ type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: n
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
|
||||
// Category state
|
||||
const selectedCategory = ref('all')
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'community', name: 'Community' },
|
||||
{ id: 'commerce', name: 'Commerce' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
{ id: 'home', name: 'Home' },
|
||||
{ id: 'car', name: 'Auto' },
|
||||
{ id: 'networking', name: 'Networking' },
|
||||
{ id: 'other', name: 'Other' }
|
||||
]
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'community', name: t('marketplace.community') },
|
||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
||||
{ id: 'money', name: t('marketplace.money') },
|
||||
{ id: 'data', name: t('marketplace.data') },
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'car', name: t('marketplace.auto') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'other', name: t('marketplace.other') }
|
||||
])
|
||||
|
||||
// Installation state - support multiple concurrent installations
|
||||
interface InstallProgress {
|
||||
@@ -425,7 +427,7 @@ watch(() => store.packages, (packages) => {
|
||||
...current,
|
||||
status: 'downloading',
|
||||
progress: Math.min(pct, 95),
|
||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
|
||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -606,7 +608,7 @@ const allApps = computed(() => {
|
||||
// Only show categories that have at least one app
|
||||
const categoriesWithApps = computed(() => {
|
||||
const apps = allApps.value
|
||||
return categories.filter(cat => {
|
||||
return categories.value.filter(cat => {
|
||||
if (cat.id === 'all') return apps.length > 0
|
||||
return apps.some(app => app.category === cat.id)
|
||||
})
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to App Store
|
||||
{{ t('marketplaceDetails.backToStore') }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button -->
|
||||
<button
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
:style="{
|
||||
@@ -20,7 +20,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to App Store</span>
|
||||
<span>{{ t('marketplaceDetails.backToStore') }}</span>
|
||||
</button>
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
@@ -30,7 +30,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">Loading app details...</p>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.loadingDetails') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- App Details -->
|
||||
@@ -63,12 +63,12 @@
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||
Installed
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
@@ -79,7 +79,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -94,7 +94,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Install' }}
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||
Installed
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -159,7 +159,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Install' }}
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium text-sm">Installation Failed</p>
|
||||
<p class="text-red-200 font-medium text-sm">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium">Installation Failed</p>
|
||||
<p class="text-red-200 font-medium">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +196,7 @@
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
@@ -208,12 +208,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">About {{ app.title }}</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.about', { name: app.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ longDescription }}
|
||||
</p>
|
||||
@@ -221,7 +221,7 @@
|
||||
|
||||
<!-- Features -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
@@ -241,22 +241,22 @@
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Version</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
|
||||
</div>
|
||||
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Developer</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ app.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Status</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? 'Installed' : 'Not Installed' }}</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? t('marketplaceDetails.installed') : t('marketplaceDetails.notInstalled') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Category</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
|
||||
</div>
|
||||
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<!-- App Dependencies -->
|
||||
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
|
||||
@@ -290,7 +290,7 @@
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
|
||||
<p class="text-white/50 text-xs">
|
||||
{{ dep.status === 'running' ? 'Running' : dep.status === 'stopped' ? 'Installed but stopped' : 'Not installed' }}
|
||||
{{ dep.status === 'running' ? t('marketplaceDetails.depRunning') : dep.status === 'stopped' ? t('marketplaceDetails.depStopped') : t('marketplaceDetails.depNotInstalled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,19 +305,19 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
{{ installingDeps ? 'Installing...' : 'Install Requirements' }}
|
||||
{{ installingDeps ? t('common.installing') : t('marketplaceDetails.installRequirements') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="py-2 border-b border-white/10">
|
||||
<p class="text-white/60 text-sm">No additional dependencies required</p>
|
||||
<p class="text-white/60 text-sm">{{ t('marketplaceDetails.noRequirements') }}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">RAM</p>
|
||||
<p class="text-white/60 text-sm">Minimum 512MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -325,8 +325,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">Storage</p>
|
||||
<p class="text-white/60 text-sm">~100MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +334,7 @@
|
||||
|
||||
<!-- Links Card (no GitHub - repo link removed per product) -->
|
||||
<div v-if="app.manifestUrl" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
:href="app.manifestUrl"
|
||||
@@ -345,7 +345,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download Package
|
||||
{{ t('marketplaceDetails.downloadPackage') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -358,8 +358,8 @@
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
|
||||
<p class="text-white/70">The requested application could not be found in the marketplace</p>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('marketplaceDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -369,11 +369,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -404,7 +406,7 @@ const shortDescription = computed(() => {
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
@@ -418,7 +420,7 @@ const longDescription = computed(() => {
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
@@ -479,7 +481,7 @@ onMounted(() => {
|
||||
}, 500)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
loading.value = false
|
||||
pendingRedirect = setTimeout(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
@@ -530,8 +532,8 @@ async function installDependencies() {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : 'Failed to install dependencies.'
|
||||
console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
|
||||
} finally {
|
||||
installingDeps.value = false
|
||||
}
|
||||
@@ -540,7 +542,7 @@ async function installDependencies() {
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value) return
|
||||
if (!app.value.manifestUrl && !app.value.dockerImage) {
|
||||
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -577,8 +579,8 @@ async function installApp() {
|
||||
|
||||
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : 'Installation failed. Please try again.'
|
||||
console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Monitoring</h1>
|
||||
<p class="text-white/70">Real-time system metrics and container resource usage</p>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('monitoring.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('monitoring.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('csv')">
|
||||
Export CSV
|
||||
{{ t('monitoring.exportCsv') }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('json')">
|
||||
Export JSON
|
||||
{{ t('monitoring.exportJson') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,22 +20,22 @@
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">CPU</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ current?.system.cpu_percent.toFixed(1) ?? '--' }}%</p>
|
||||
<p class="text-xs text-white/40">Load: {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Memory</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.mem_used_bytes ?? 0) }} / {{ formatBytes(current?.system.mem_total_bytes ?? 0) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Disk</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.disk_used_bytes ?? 0) }} / {{ formatBytes(current?.system.disk_total_bytes ?? 0) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Network</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(current?.system.net_rx_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(current?.system.net_tx_bytes ?? 0) }}</p>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">CPU Usage (%)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.cpuUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="cpuDatasets"
|
||||
:labels="timeLabels"
|
||||
@@ -54,7 +54,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">Memory Usage (%)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.memoryUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="memDatasets"
|
||||
:labels="timeLabels"
|
||||
@@ -64,7 +64,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">Network I/O (bytes)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.networkIo') }}</h3>
|
||||
<LineChart
|
||||
:datasets="netDatasets"
|
||||
:labels="timeLabels"
|
||||
@@ -73,7 +73,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">RPC Latency (ms)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.rpcLatency') }}</h3>
|
||||
<LineChart
|
||||
:datasets="latencyDatasets"
|
||||
:labels="timeLabels"
|
||||
@@ -86,12 +86,12 @@
|
||||
<!-- Alert History -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">Alert History</h3>
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.alertHistory') }}</h3>
|
||||
<button
|
||||
class="glass-button text-xs px-3 py-1"
|
||||
@click="showAlertConfig = !showAlertConfig"
|
||||
>
|
||||
{{ showAlertConfig ? 'Hide Config' : 'Configure' }}
|
||||
{{ showAlertConfig ? t('monitoring.hideConfig') : t('common.configure') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
|
||||
<!-- Fired Alerts List -->
|
||||
<div v-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
|
||||
No alerts fired
|
||||
{{ t('monitoring.noAlerts') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
@@ -150,7 +150,7 @@
|
||||
class="text-xs text-white/40 hover:text-white/70 flex-shrink-0"
|
||||
@click="acknowledgeAlert(alert.id)"
|
||||
>
|
||||
Dismiss
|
||||
{{ t('common.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,9 +158,9 @@
|
||||
|
||||
<!-- Container Resource Breakdown -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">Container Resources</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">{{ t('monitoring.containerResources') }}</h3>
|
||||
<div v-if="!containers.length" class="text-white/40 text-sm py-4 text-center">
|
||||
No container metrics available
|
||||
{{ t('monitoring.noContainerMetrics') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
@@ -190,11 +190,11 @@
|
||||
<!-- System Health Timeline -->
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">System Health</h3>
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.systemHealth') }}</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> Healthy
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> Elevated
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> Critical
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> {{ t('common.healthy') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> {{ t('common.elevated') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> {{ t('common.critical') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-0.5 h-6">
|
||||
@@ -213,13 +213,14 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/30 mt-4 text-center">
|
||||
Refreshing every 5 seconds · WS connections: {{ current?.ws_connections ?? 0 }}
|
||||
{{ t('monitoring.refreshFooter') }} · {{ t('monitoring.wsConnections', { count: current?.ws_connections ?? 0 }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
@@ -279,6 +280,8 @@ interface FiredAlert {
|
||||
acknowledged: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
const history = ref<MetricSnapshot[]>([])
|
||||
const containers = ref<ContainerMetrics[]>([])
|
||||
@@ -382,11 +385,11 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
function ruleLabel(kind: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
disk_usage: 'Disk Usage',
|
||||
ram_usage: 'RAM Usage',
|
||||
container_crash: 'Container Crash',
|
||||
backend_error_spike: 'RPC Latency Spike',
|
||||
ssl_cert_expiry: 'SSL Cert Expiry',
|
||||
disk_usage: t('monitoring.diskUsage'),
|
||||
ram_usage: t('monitoring.ramUsage'),
|
||||
container_crash: t('monitoring.containerCrash'),
|
||||
backend_error_spike: t('monitoring.rpcLatencySpike'),
|
||||
ssl_cert_expiry: t('monitoring.sslCertExpiry'),
|
||||
}
|
||||
return labels[kind] ?? kind
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div>
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ t('settings.title') }}</h1>
|
||||
<p class="text-white/80">{{ t('settings.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||
<div class="md:hidden">
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-6">Account</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-6">{{ t('settings.account') }}</h2>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
@@ -23,7 +23,7 @@
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Server Name</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.serverName') }}</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Version</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('common.version') }}</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
|
||||
</div>
|
||||
@@ -45,9 +45,9 @@
|
||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Session Status</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.sessionStatus') }}</p>
|
||||
</div>
|
||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||
<p class="text-base font-medium text-white/90">{{ t('settings.loggedIn') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity Card: DID + Tor Address (onion below DID, with copy) -->
|
||||
@@ -59,7 +59,7 @@
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.yourDid') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyDid"
|
||||
@@ -68,12 +68,12 @@
|
||||
<svg v-if="!copiedDid" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||
<span v-if="!copiedDid">Copy</span>
|
||||
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
|
||||
<span v-if="!copiedDid">{{ t('common.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ t('settings.didHelper') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tor / Onion Address (below DID, with copy button) -->
|
||||
@@ -83,7 +83,7 @@
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node .onion Address</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyOnionAddress"
|
||||
@@ -92,12 +92,12 @@
|
||||
<svg v-if="!copiedOnion" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||
<span v-if="!copiedOnion">Copy</span>
|
||||
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
|
||||
<span v-if="!copiedOnion">{{ t('common.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Onion address for node interface and peer discovery over Tor</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ t('settings.onionHelper') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span>Change Password</span>
|
||||
<span>{{ t('settings.changePassword') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -123,45 +123,45 @@
|
||||
@click.self="closeChangePasswordModal()"
|
||||
>
|
||||
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">{{ t('settings.changePasswordTitle') }}</h3>
|
||||
<p class="text-white/70 text-sm mb-4">{{ t('settings.changePasswordDesc') }}</p>
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Current Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.currentPassword') }}</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter current password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">New Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.newPassword') }}</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="12+ chars, upper, lower, digit, special"
|
||||
:placeholder="t('settings.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Confirm New Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.confirmNewPassword') }}</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Re-enter new password"
|
||||
:placeholder="t('settings.confirmNewPassword')"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
|
||||
Also update SSH password (recommended)
|
||||
{{ t('settings.updateSshCheckbox') }}
|
||||
</label>
|
||||
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
|
||||
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
|
||||
@@ -171,14 +171,14 @@
|
||||
:disabled="changingPassword"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ changingPassword ? 'Updating...' : 'Update Password' }}
|
||||
{{ changingPassword ? t('settings.updatingPassword') : t('settings.updatePassword') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -194,15 +194,15 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white/90">Two-Factor Authentication</p>
|
||||
<p class="text-xs text-white/50">Protect your account with an authenticator app</p>
|
||||
<p class="text-sm font-medium text-white/90">{{ t('settings.twoFactorAuth') }}</p>
|
||||
<p class="text-xs text-white/50">{{ t('settings.twoFaProtect') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-semibold px-2 py-1 rounded-full"
|
||||
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
|
||||
>
|
||||
{{ totpEnabled ? 'Enabled' : 'Disabled' }}
|
||||
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -213,7 +213,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Enable 2FA</span>
|
||||
<span>{{ t('settings.enable2fa') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -223,7 +223,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Disable 2FA</span>
|
||||
<span>{{ t('settings.disable2fa') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -238,8 +238,8 @@
|
||||
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-setup-title">
|
||||
<!-- Step 1: Enter password -->
|
||||
<template v-if="totpSetupStep === 1">
|
||||
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">Enable Two-Factor Authentication</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Enter your password to begin setup.</p>
|
||||
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.setup2faTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.setup2faPasswordPrompt') }}</p>
|
||||
<form @submit.prevent="beginTotpSetup" class="space-y-4">
|
||||
<input
|
||||
v-model="totpSetupPassword"
|
||||
@@ -247,7 +247,7 @@
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
/>
|
||||
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
|
||||
<div class="flex gap-3">
|
||||
@@ -256,20 +256,20 @@
|
||||
:disabled="totpSetupLoading"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpSetupLoading ? 'Loading...' : 'Continue' }}
|
||||
{{ totpSetupLoading ? t('common.loading') : t('common.continue') }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Scan QR + verify code -->
|
||||
<template v-else-if="totpSetupStep === 2">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Scan QR Code</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.</p>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
|
||||
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" />
|
||||
<div class="bg-black/30 rounded-lg px-3 py-2 mb-4">
|
||||
<p class="text-xs text-white/50 mb-1">Manual entry key:</p>
|
||||
<p class="text-xs text-white/50 mb-1">{{ t('settings.manualEntryKey') }}</p>
|
||||
<p class="text-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p>
|
||||
</div>
|
||||
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
|
||||
@@ -282,7 +282,7 @@
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
|
||||
placeholder="000000"
|
||||
:placeholder="t('login.totpPlaceholder')"
|
||||
/>
|
||||
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
|
||||
<div class="flex gap-3">
|
||||
@@ -291,17 +291,17 @@
|
||||
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpSetupLoading ? 'Verifying...' : 'Verify & Enable' }}
|
||||
{{ totpSetupLoading ? t('login.verifying') : t('settings.verifyAndEnable') }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: Show backup codes -->
|
||||
<template v-else-if="totpSetupStep === 3">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Save Your Backup Codes</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Store these codes safely. Each can be used once if you lose access to your authenticator app.</p>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.saveBackupCodes') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.backupCodesInstruction') }}</p>
|
||||
<div class="bg-black/30 rounded-xl p-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
@@ -320,13 +320,13 @@
|
||||
<svg v-if="!backupCodesCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ backupCodesCopied ? 'Copied!' : 'Copy All Codes' }}</span>
|
||||
<span>{{ backupCodesCopied ? t('common.copiedBang') : t('settings.copyAllCodes') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="closeTotpSetup"
|
||||
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Done
|
||||
{{ t('common.done') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -342,22 +342,22 @@
|
||||
@keydown.escape="closeTotpDisable"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-disable-title">
|
||||
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">Disable Two-Factor Authentication</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Enter your password and a current TOTP code to disable 2FA.</p>
|
||||
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.disable2faTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.disable2faDesc') }}</p>
|
||||
<form @submit.prevent="disableTotp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('login.password') }}</label>
|
||||
<input
|
||||
v-model="totpDisablePassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Authenticator Code</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.authenticatorCode') }}</label>
|
||||
<input
|
||||
v-model="totpDisableCode"
|
||||
type="text"
|
||||
@@ -367,7 +367,7 @@
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
|
||||
placeholder="000000"
|
||||
:placeholder="t('login.totpPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
|
||||
@@ -377,9 +377,9 @@
|
||||
:disabled="totpDisableLoading"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpDisableLoading ? 'Disabling...' : 'Disable 2FA' }}
|
||||
{{ totpDisableLoading ? t('common.disabling') : t('settings.disable2fa') }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -394,14 +394,14 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
<span>{{ t('settings.logout') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Interface Mode Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Interface Mode</h2>
|
||||
<p class="text-sm text-white/60 mb-6">Choose how you want to interact with your node.</p>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
@@ -431,8 +431,8 @@
|
||||
|
||||
<!-- Claude Authentication Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Claude Authentication</h2>
|
||||
<p class="text-sm text-white/60 mb-6">Connect your Claude Max account to enable AI chat features.</p>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
|
||||
@@ -441,10 +441,10 @@
|
||||
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Connection Status</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.connectionStatus') }}</p>
|
||||
</div>
|
||||
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
|
||||
{{ claudeConnected ? 'Connected' : 'Not connected' }}
|
||||
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>{{ claudeConnected ? 'Re-authenticate' : 'Login with Claude' }}</span>
|
||||
<span>{{ claudeConnected ? t('settings.reAuthenticate') : t('settings.loginWithClaude') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -471,7 +471,7 @@
|
||||
>
|
||||
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<h3 class="text-sm font-semibold text-white/80">Claude Authentication</h3>
|
||||
<h3 class="text-sm font-semibold text-white/80">{{ t('settings.claudeAuth') }}</h3>
|
||||
<button @click="showClaudeLoginModal = false" class="text-white/50 hover:text-white/80 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -491,9 +491,9 @@
|
||||
<!-- AI Data Access Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="mb-2">
|
||||
<h2 class="text-xl font-semibold text-white/96">AI Data Access</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.aiDataAccess') }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-6">Control what data the AI assistant can see. All categories are off by default.</p>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.aiDataAccessDesc') }}</p>
|
||||
|
||||
<!-- Enable All toggle -->
|
||||
<button
|
||||
@@ -507,8 +507,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">Enable All</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Grant access to all data categories at once</p>
|
||||
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
@@ -560,14 +560,14 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">System Updates</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Check for and install software updates</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Manage Updates
|
||||
{{ t('common.manageUpdates') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,18 +576,18 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Webhook Notifications</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Get push notifications for critical events via webhook</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="toggleWebhookEnabled"
|
||||
role="switch"
|
||||
:aria-checked="webhookConfig.enabled"
|
||||
:aria-label="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
|
||||
:aria-label="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
|
||||
:title="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
|
||||
:title="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
@@ -600,29 +600,29 @@
|
||||
<div class="space-y-4">
|
||||
<!-- Webhook URL -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Webhook URL</label>
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookUrlLabel') }}</label>
|
||||
<input
|
||||
v-model="webhookConfig.url"
|
||||
type="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
:placeholder="t('settings.webhookUrlPlaceholder')"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Secret (optional) -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Secret (optional, for HMAC-SHA256 signing)</label>
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookSecretLabel') }}</label>
|
||||
<input
|
||||
v-model="webhookConfig.secret"
|
||||
type="password"
|
||||
placeholder="Shared secret for payload signing"
|
||||
:placeholder="t('settings.webhookSecretPlaceholderFull')"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-2">Events to notify</label>
|
||||
<label class="text-xs text-white/50 block mb-2">{{ t('settings.eventsToNotify') }}</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="evt in webhookEventTypes"
|
||||
@@ -661,14 +661,14 @@
|
||||
:disabled="savingWebhook"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
||||
>
|
||||
{{ savingWebhook ? 'Saving...' : 'Save Configuration' }}
|
||||
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
|
||||
</button>
|
||||
<button
|
||||
@click="testWebhook"
|
||||
:disabled="testingWebhook || !webhookConfig.url"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{{ testingWebhook ? 'Sending...' : 'Send Test' }}
|
||||
{{ testingWebhook ? t('common.sending') : t('common.sendTest') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -683,37 +683,37 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Backup & Restore</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Encrypted backups of your identity, settings, and data</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.backup') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.backupRestoreDesc') }}</p>
|
||||
</div>
|
||||
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Backup
|
||||
{{ t('settings.createBackup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backup List -->
|
||||
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">Loading backups...</div>
|
||||
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">No backups yet. Create one to protect your node data.</div>
|
||||
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
|
||||
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-white font-medium">{{ b.description || 'System Backup' }}</div>
|
||||
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
|
||||
<div class="text-xs text-white/50">{{ new Date(b.created_at).toLocaleString() }} · {{ formatBackupSize(b.size_bytes) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" title="Verify">
|
||||
{{ verifyingBackupId === b.id ? '...' : 'Verify' }}
|
||||
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" :title="t('common.verify')">
|
||||
{{ verifyingBackupId === b.id ? '...' : t('common.verify') }}
|
||||
</button>
|
||||
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" title="Copy to USB">
|
||||
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
|
||||
{{ usbCopyingId === b.id ? '...' : 'USB' }}
|
||||
</button>
|
||||
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" title="Restore">
|
||||
Restore
|
||||
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
|
||||
{{ t('common.restore') }}
|
||||
</button>
|
||||
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" aria-label="Delete backup" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" title="Delete">
|
||||
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" :aria-label="t('settings.deleteBackup')" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" :title="t('common.delete')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -730,21 +730,21 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showCreateBackupModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
|
||||
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">Create Encrypted Backup</h3>
|
||||
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
|
||||
<input v-model="backupPassphrase" type="password" placeholder="Enter a strong passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
|
||||
<input v-model="backupPassphrase" type="password" :placeholder="t('settings.enterPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Description (optional)</label>
|
||||
<input v-model="backupDescription" type="text" placeholder="e.g. Before update" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.descriptionOptional') }}</label>
|
||||
<input v-model="backupDescription" type="text" :placeholder="t('settings.descriptionPlaceholder')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
|
||||
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
||||
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
||||
{{ creatingBackup ? 'Creating...' : 'Create Backup' }}
|
||||
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,16 +755,16 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showRestoreModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
|
||||
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">Restore Backup</h3>
|
||||
<p class="text-sm text-red-400/80 mb-4">This will overwrite current node data. Make sure you have the correct passphrase.</p>
|
||||
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
|
||||
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
|
||||
<input v-model="restorePassphrase" type="password" placeholder="Enter backup passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
|
||||
<input v-model="restorePassphrase" type="password" :placeholder="t('settings.enterBackupPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
|
||||
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
||||
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50">
|
||||
{{ restoringBackup ? 'Restoring...' : 'Restore' }}
|
||||
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -775,14 +775,14 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Network</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Network connectivity, UPnP, and diagnostics</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.networkDesc') }}</p>
|
||||
</div>
|
||||
<button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
Network Diagnostics
|
||||
{{ t('common.networkDiagnostics') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -792,6 +792,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
@@ -801,6 +802,7 @@ import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
const uiMode = useUIModeStore()
|
||||
const aiPermissions = useAIPermissionsStore()
|
||||
@@ -817,26 +819,26 @@ const aiCategoryGroups = computed(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
|
||||
const interfaceModes = computed<{ id: UIMode; label: string; description: string; iconPaths: string[] }[]>(() => [
|
||||
{
|
||||
id: 'easy',
|
||||
label: 'Easy',
|
||||
description: 'Goal-based interface. Choose what you want to do, and the system handles the rest.',
|
||||
label: t('settings.modeEasy'),
|
||||
description: t('settings.modeEasyDesc'),
|
||||
iconPaths: ['M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
|
||||
},
|
||||
{
|
||||
id: 'gamer',
|
||||
label: 'Pro',
|
||||
description: 'Full control over all services. Configure everything manually with all technical details.',
|
||||
label: t('settings.modePro'),
|
||||
description: t('settings.modeProDesc'),
|
||||
iconPaths: ['M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'],
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
description: 'Conversational AI interface. Manage your node through natural language. Coming soon.',
|
||||
label: t('settings.modeChat'),
|
||||
description: t('settings.modeChatDesc'),
|
||||
iconPaths: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
@@ -928,7 +930,7 @@ async function beginTotpSetup() {
|
||||
totpPendingToken.value = res.pending_token
|
||||
totpSetupStep.value = 2
|
||||
} catch (e) {
|
||||
totpSetupError.value = e instanceof Error ? e.message : 'Setup failed'
|
||||
totpSetupError.value = e instanceof Error ? e.message : t('settings.setupFailed')
|
||||
} finally {
|
||||
totpSetupLoading.value = false
|
||||
}
|
||||
@@ -947,7 +949,7 @@ async function confirmTotpSetup() {
|
||||
totpEnabled.value = true
|
||||
totpSetupStep.value = 3
|
||||
} catch (e) {
|
||||
totpSetupError.value = e instanceof Error ? e.message : 'Verification failed'
|
||||
totpSetupError.value = e instanceof Error ? e.message : t('settings.verificationFailed')
|
||||
} finally {
|
||||
totpSetupLoading.value = false
|
||||
}
|
||||
@@ -974,7 +976,7 @@ async function disableTotp() {
|
||||
totpEnabled.value = false
|
||||
closeTotpDisable()
|
||||
} catch (e) {
|
||||
totpDisableError.value = e instanceof Error ? e.message : 'Failed to disable 2FA'
|
||||
totpDisableError.value = e instanceof Error ? e.message : t('settings.disableFailed')
|
||||
} finally {
|
||||
totpDisableLoading.value = false
|
||||
}
|
||||
@@ -1022,11 +1024,11 @@ const changePasswordForm = ref({
|
||||
})
|
||||
|
||||
function validatePasswordStrength(pw: string): string | null {
|
||||
if (pw.length < 12) return 'Password must be at least 12 characters'
|
||||
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter'
|
||||
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter'
|
||||
if (!/\d/.test(pw)) return 'Password must contain at least one digit'
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain at least one special character (!@#$%^&* etc.)'
|
||||
if (pw.length < 12) return t('settings.passwordMinLength')
|
||||
if (!/[A-Z]/.test(pw)) return t('settings.passwordNeedUppercase')
|
||||
if (!/[a-z]/.test(pw)) return t('settings.passwordNeedLowercase')
|
||||
if (!/\d/.test(pw)) return t('settings.passwordNeedDigit')
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return t('settings.passwordNeedSpecial')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1035,11 +1037,11 @@ async function handleChangePassword() {
|
||||
changePasswordSuccess.value = ''
|
||||
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
changePasswordError.value = 'All fields are required'
|
||||
changePasswordError.value = t('settings.passwordAllFieldsRequired')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
changePasswordError.value = 'New passwords do not match'
|
||||
changePasswordError.value = t('settings.passwordMismatch')
|
||||
return
|
||||
}
|
||||
const strengthError = validatePasswordStrength(newPassword)
|
||||
@@ -1054,13 +1056,13 @@ async function handleChangePassword() {
|
||||
newPassword,
|
||||
alsoChangeSsh,
|
||||
})
|
||||
changePasswordSuccess.value = 'Password updated successfully. Use the new password for login and SSH.'
|
||||
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
changePasswordError.value = e instanceof Error ? e.message : 'Failed to change password'
|
||||
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
@@ -1190,28 +1192,28 @@ async function createBackup() {
|
||||
showCreateBackupModal.value = false
|
||||
backupPassphrase.value = ''
|
||||
backupDescription.value = ''
|
||||
showBackupStatus('Backup created successfully', 'success')
|
||||
showBackupStatus(t('settings.backupCreatedSuccess'), 'success')
|
||||
await loadBackups()
|
||||
} catch {
|
||||
showBackupStatus('Failed to create backup', 'error')
|
||||
showBackupStatus(t('settings.backupCreateFailed'), 'error')
|
||||
} finally {
|
||||
creatingBackup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBackup(id: string) {
|
||||
const passphrase = prompt('Enter backup passphrase to verify:')
|
||||
const passphrase = prompt(t('settings.verifyPassphrasePrompt'))
|
||||
if (!passphrase) return
|
||||
verifyingBackupId.value = id
|
||||
try {
|
||||
const res = await rpcClient.call<{ valid: boolean; error: string | null }>({ method: 'backup.verify', params: { id, passphrase } })
|
||||
if (res.valid) {
|
||||
showBackupStatus('Backup verified — integrity OK', 'success')
|
||||
showBackupStatus(t('settings.backupVerifiedOk'), 'success')
|
||||
} else {
|
||||
showBackupStatus(`Verification failed: ${res.error || 'Unknown error'}`, 'error')
|
||||
showBackupStatus(t('settings.backupVerifyFailed', { error: res.error || 'Unknown error' }), 'error')
|
||||
}
|
||||
} catch {
|
||||
showBackupStatus('Verification request failed', 'error')
|
||||
showBackupStatus(t('settings.backupVerifyRequestFailed'), 'error')
|
||||
} finally {
|
||||
verifyingBackupId.value = null
|
||||
}
|
||||
@@ -1229,23 +1231,23 @@ async function restoreBackup() {
|
||||
try {
|
||||
await rpcClient.call({ method: 'backup.restore', params: { id: restoreBackupId.value, passphrase: restorePassphrase.value } })
|
||||
showRestoreModal.value = false
|
||||
showBackupStatus('Backup restored. Restart may be needed.', 'success')
|
||||
showBackupStatus(t('settings.backupRestored'), 'success')
|
||||
} catch {
|
||||
showBackupStatus('Restore failed — check passphrase', 'error')
|
||||
showBackupStatus(t('settings.backupRestoreFailed'), 'error')
|
||||
} finally {
|
||||
restoringBackup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup(id: string) {
|
||||
if (!confirm('Delete this backup permanently?')) return
|
||||
if (!confirm(t('settings.deleteBackupConfirm'))) return
|
||||
deletingBackupId.value = id
|
||||
try {
|
||||
await rpcClient.call({ method: 'backup.delete', params: { id } })
|
||||
showBackupStatus('Backup deleted', 'success')
|
||||
showBackupStatus(t('settings.backupDeleted'), 'success')
|
||||
await loadBackups()
|
||||
} catch {
|
||||
showBackupStatus('Failed to delete backup', 'error')
|
||||
showBackupStatus(t('settings.backupDeleteFailed'), 'error')
|
||||
} finally {
|
||||
deletingBackupId.value = null
|
||||
}
|
||||
@@ -1269,12 +1271,12 @@ const testingWebhook = ref(false)
|
||||
const webhookStatusMsg = ref('')
|
||||
const webhookStatusType = ref<'success' | 'error'>('success')
|
||||
|
||||
const webhookEventTypes = [
|
||||
{ id: 'container_crash', label: 'Container Crash', description: 'A running container stops unexpectedly' },
|
||||
{ id: 'update_available', label: 'Update Available', description: 'A new system or app update is ready' },
|
||||
{ id: 'disk_warning', label: 'Disk Space Warning', description: 'Disk usage exceeds warning threshold' },
|
||||
{ id: 'backup_complete', label: 'Backup Complete', description: 'A scheduled or manual backup finishes' },
|
||||
]
|
||||
const webhookEventTypes = computed(() => [
|
||||
{ id: 'container_crash', label: t('settings.containerCrash'), description: t('settings.containerCrashDesc') },
|
||||
{ id: 'update_available', label: t('settings.updateAvailableEvent'), description: t('settings.updateAvailableDesc') },
|
||||
{ id: 'disk_warning', label: t('settings.diskSpaceWarning'), description: t('settings.diskWarningDesc') },
|
||||
{ id: 'backup_complete', label: t('settings.backupComplete'), description: t('settings.backupCompleteDesc') },
|
||||
])
|
||||
|
||||
function showWebhookStatus(msg: string, type: 'success' | 'error') {
|
||||
webhookStatusMsg.value = msg
|
||||
@@ -1319,9 +1321,9 @@ async function saveWebhookConfig() {
|
||||
events: webhookConfig.value.events,
|
||||
},
|
||||
})
|
||||
showWebhookStatus('Webhook configuration saved', 'success')
|
||||
showWebhookStatus(t('settings.webhookSaved'), 'success')
|
||||
} catch {
|
||||
showWebhookStatus('Failed to save webhook configuration', 'error')
|
||||
showWebhookStatus(t('settings.webhookSaveFailed'), 'error')
|
||||
} finally {
|
||||
savingWebhook.value = false
|
||||
}
|
||||
@@ -1332,12 +1334,12 @@ async function testWebhook() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ sent: boolean; url: string }>({ method: 'webhook.test' })
|
||||
if (res.sent) {
|
||||
showWebhookStatus('Test webhook sent successfully', 'success')
|
||||
showWebhookStatus(t('settings.webhookTestSent'), 'success')
|
||||
} else {
|
||||
showWebhookStatus('Test failed: webhook not sent', 'error')
|
||||
showWebhookStatus(t('settings.webhookTestFailed'), 'error')
|
||||
}
|
||||
} catch {
|
||||
showWebhookStatus('Failed to send test webhook', 'error')
|
||||
showWebhookStatus(t('settings.webhookSendFailed'), 'error')
|
||||
} finally {
|
||||
testingWebhook.value = false
|
||||
}
|
||||
@@ -1361,15 +1363,15 @@ async function backupToUsb(backupId: string) {
|
||||
const mounted = drives.filter(d => d.mount_point)
|
||||
const target = mounted[0]
|
||||
if (!target?.mount_point) {
|
||||
showBackupStatus('No mounted USB drives found. Insert and mount a USB drive first.', 'error')
|
||||
showBackupStatus(t('settings.noUsbDrives'), 'error')
|
||||
return
|
||||
}
|
||||
const label = target.label || target.device
|
||||
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
|
||||
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
|
||||
showBackupStatus(`Backup copied to ${target.mount_point}`, 'success')
|
||||
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
|
||||
} catch {
|
||||
showBackupStatus('Failed to copy backup to USB', 'error')
|
||||
showBackupStatus(t('settings.backupUsbFailed'), 'error')
|
||||
} finally {
|
||||
usbCopyingId.value = null
|
||||
}
|
||||
|
||||
422
neode-ui/src/views/SystemUpdate.vue
Normal file
422
neode-ui/src/views/SystemUpdate.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Current Version -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.currentSystem') }}</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('common.version') }}</p>
|
||||
<p class="text-xl font-bold text-white">v{{ currentVersion }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
|
||||
<p class="text-sm font-medium text-white">{{ lastCheckDisplay }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('common.status') }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="statusDotColor"></div>
|
||||
<p class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Update -->
|
||||
<div v-if="updateInfo" class="glass-card p-6 mb-6 border border-orange-400/30">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
|
||||
<p class="text-sm text-white/60">Version {{ updateInfo.version }} — {{ updateInfo.release_date }}</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Changelog -->
|
||||
<div v-if="updateInfo.changelog.length" class="mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-2">{{ t('systemUpdate.changelog') }}</h3>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="(entry, i) in updateInfo.changelog" :key="i" class="text-sm text-white/60 flex gap-2">
|
||||
<span class="text-orange-400 shrink-0">•</span>
|
||||
<span>{{ entry }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Components -->
|
||||
<div v-if="updateInfo.components > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.componentsToUpdate', { count: updateInfo.components }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-if="!downloading && !applying"
|
||||
@click="downloadUpdate"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('systemUpdate.downloadUpdate') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="downloaded && !applying"
|
||||
@click="requestApply"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
>
|
||||
{{ t('systemUpdate.applyUpdate') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No update available -->
|
||||
<div v-else-if="!loading" class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.upToDate') }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">{{ t('systemUpdate.upToDateMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Download Progress -->
|
||||
<div v-if="downloading" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
|
||||
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400 rounded-full transition-all duration-500"
|
||||
:style="{ width: downloadPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercent }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Applying -->
|
||||
<div v-if="applying" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Schedule -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-2">{{ t('systemUpdate.updateSchedule') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('systemUpdate.subtitle') }}</p>
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
v-for="opt in scheduleOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
|
||||
:class="{ 'ring-1 ring-orange-400/50 bg-orange-500/10': schedule === opt.value }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="update-schedule"
|
||||
:value="opt.value"
|
||||
:checked="schedule === opt.value"
|
||||
@change="setSchedule(opt.value)"
|
||||
class="mt-1 accent-orange-400"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
|
||||
<p class="text-xs text-white/50">{{ opt.description }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="checkForUpdates"
|
||||
:disabled="loading"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="rollbackAvailable"
|
||||
@click="requestRollback"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
|
||||
>
|
||||
{{ t('systemUpdate.rollback') }}
|
||||
</button>
|
||||
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
|
||||
{{ t('systemUpdate.backToSettings') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'apply'
|
||||
? t('systemUpdate.applyMessage')
|
||||
: t('systemUpdate.rollbackMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'apply' ? t('systemUpdate.applyNow') : t('systemUpdate.rollbackButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface UpdateDetail {
|
||||
version: string
|
||||
release_date: string
|
||||
changelog: string[]
|
||||
components: number
|
||||
}
|
||||
|
||||
type ScheduleValue = 'manual' | 'daily_check' | 'auto_apply'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const scheduleOptions = computed<{ value: ScheduleValue; label: string; description: string }[]>(() => [
|
||||
{ value: 'manual', label: t('systemUpdate.manualOnly'), description: t('systemUpdate.manualOnlyDesc') },
|
||||
{ value: 'daily_check', label: t('systemUpdate.dailyCheck'), description: t('systemUpdate.dailyCheckDesc') },
|
||||
{ value: 'auto_apply', label: t('systemUpdate.autoApply'), description: t('systemUpdate.autoApplyDesc') },
|
||||
])
|
||||
|
||||
const schedule = ref<ScheduleValue>('daily_check')
|
||||
const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const applying = ref(false)
|
||||
const confirmAction = ref<'apply' | 'rollback' | null>(null)
|
||||
const currentVersion = ref('0.0.0')
|
||||
const lastCheck = ref<string | null>(null)
|
||||
const updateInfo = ref<UpdateDetail | null>(null)
|
||||
const rollbackAvailable = ref(false)
|
||||
const updateInProgress = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
|
||||
const lastCheckDisplay = computed(() => {
|
||||
if (!lastCheck.value) return t('common.never')
|
||||
try {
|
||||
const d = new Date(lastCheck.value)
|
||||
return d.toLocaleString()
|
||||
} catch {
|
||||
return lastCheck.value
|
||||
}
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (applying.value) return t('systemUpdate.applying')
|
||||
if (downloading.value) return t('systemUpdate.downloading')
|
||||
if (updateInProgress.value) return t('systemUpdate.applying')
|
||||
if (updateInfo.value) return t('systemUpdate.updateAvailable')
|
||||
if (rollbackAvailable.value) return t('systemUpdate.rollback')
|
||||
return t('systemUpdate.upToDate')
|
||||
})
|
||||
|
||||
const statusDotColor = computed(() => {
|
||||
if (applying.value || downloading.value) return 'bg-orange-400 animate-pulse'
|
||||
if (updateInfo.value || updateInProgress.value) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
if (applying.value || downloading.value || updateInfo.value || updateInProgress.value) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
|
||||
function showStatus(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
current_version: string
|
||||
last_check: string | null
|
||||
update_available: boolean
|
||||
update_in_progress: boolean
|
||||
rollback_available: boolean
|
||||
}>({ method: 'update.status' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInProgress.value = res.update_in_progress
|
||||
rollbackAvailable.value = res.rollback_available
|
||||
|
||||
if (res.update_in_progress) {
|
||||
downloaded.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
loading.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
current_version: string
|
||||
last_check: string | null
|
||||
update_available: boolean
|
||||
update: UpdateDetail | null
|
||||
}>({ method: 'update.check' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInfo.value = res.update
|
||||
if (!res.update_available) {
|
||||
showStatus(t('systemUpdate.upToDateMessage'))
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.checkFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Update check failed', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUpdate() {
|
||||
downloading.value = true
|
||||
downloadPercent.value = 0
|
||||
statusMessage.value = ''
|
||||
|
||||
// Simulate incremental progress while waiting for the RPC
|
||||
const progressInterval = setInterval(() => {
|
||||
if (downloadPercent.value < 90) {
|
||||
downloadPercent.value += Math.random() * 15
|
||||
}
|
||||
}, 500)
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
total_bytes: number
|
||||
downloaded_bytes: number
|
||||
components_downloaded: number
|
||||
}>({ method: 'update.download' })
|
||||
downloadPercent.value = 100
|
||||
downloaded.value = true
|
||||
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
||||
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.downloadFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Download failed', e)
|
||||
} finally {
|
||||
clearInterval(progressInterval)
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestApply() {
|
||||
confirmAction.value = 'apply'
|
||||
}
|
||||
|
||||
function requestRollback() {
|
||||
confirmAction.value = 'rollback'
|
||||
}
|
||||
|
||||
function cancelConfirm() {
|
||||
confirmAction.value = null
|
||||
}
|
||||
|
||||
async function executeConfirm() {
|
||||
if (confirmAction.value === 'apply') {
|
||||
confirmAction.value = null
|
||||
await applyUpdate()
|
||||
} else if (confirmAction.value === 'rollback') {
|
||||
confirmAction.value = null
|
||||
await rollbackUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.apply' })
|
||||
showStatus(t('systemUpdate.applySuccess'))
|
||||
updateInfo.value = null
|
||||
downloaded.value = false
|
||||
await loadStatus()
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackUpdate() {
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.rollback' })
|
||||
showStatus(t('systemUpdate.rollbackSuccess'))
|
||||
rollbackAvailable.value = false
|
||||
await loadStatus()
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.rollbackFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Rollback failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ schedule: ScheduleValue }>({ method: 'update.get-schedule' })
|
||||
schedule.value = res.schedule
|
||||
} catch {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load update schedule')
|
||||
}
|
||||
}
|
||||
|
||||
async function setSchedule(value: ScheduleValue) {
|
||||
schedule.value = value
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.set-schedule', params: { schedule: value } })
|
||||
showStatus(`Schedule set to ${scheduleOptions.value.find(o => o.value === value)?.label}`)
|
||||
} catch (e) {
|
||||
showStatus('Failed to save schedule', true)
|
||||
if (import.meta.env.DEV) console.warn('Set schedule failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user