Spaces:
Build error
Build error
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- app/components/@settings/core/AvatarDropdown.tsx +158 -0
- app/components/@settings/core/ControlPanel.tsx +555 -0
- app/components/@settings/core/constants.ts +88 -0
- app/components/@settings/core/types.ts +114 -0
- app/components/@settings/index.ts +14 -0
- app/components/@settings/shared/components/DraggableTabList.tsx +163 -0
- app/components/@settings/shared/components/TabManagement.tsx +380 -0
- app/components/@settings/shared/components/TabTile.tsx +135 -0
- app/components/@settings/tabs/connections/ConnectionsTab.tsx +28 -0
- app/components/@settings/tabs/connections/GithubConnection.tsx +557 -0
- app/components/@settings/tabs/connections/NetlifyConnection.tsx +263 -0
- app/components/@settings/tabs/connections/components/ConnectionForm.tsx +180 -0
- app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx +150 -0
- app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx +528 -0
- app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +693 -0
- app/components/@settings/tabs/connections/types/GitHub.ts +95 -0
- app/components/@settings/tabs/data/DataTab.tsx +452 -0
- app/components/@settings/tabs/debug/DebugTab.tsx +2045 -0
- app/components/@settings/tabs/event-logs/EventLogsTab.tsx +1013 -0
- app/components/@settings/tabs/features/FeaturesTab.tsx +295 -0
- app/components/@settings/tabs/notifications/NotificationsTab.tsx +300 -0
- app/components/@settings/tabs/profile/ProfileTab.tsx +181 -0
- app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx +305 -0
- app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx +777 -0
- app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx +603 -0
- app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx +135 -0
- app/components/@settings/tabs/providers/service-status/base-provider.ts +121 -0
- app/components/@settings/tabs/providers/service-status/provider-factory.ts +154 -0
- app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts +76 -0
- app/components/@settings/tabs/providers/service-status/providers/anthropic.ts +80 -0
- app/components/@settings/tabs/providers/service-status/providers/cohere.ts +91 -0
- app/components/@settings/tabs/providers/service-status/providers/deepseek.ts +40 -0
- app/components/@settings/tabs/providers/service-status/providers/google.ts +77 -0
- app/components/@settings/tabs/providers/service-status/providers/groq.ts +72 -0
- app/components/@settings/tabs/providers/service-status/providers/huggingface.ts +98 -0
- app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts +40 -0
- app/components/@settings/tabs/providers/service-status/providers/mistral.ts +76 -0
- app/components/@settings/tabs/providers/service-status/providers/openai.ts +99 -0
- app/components/@settings/tabs/providers/service-status/providers/openrouter.ts +91 -0
- app/components/@settings/tabs/providers/service-status/providers/perplexity.ts +91 -0
- app/components/@settings/tabs/providers/service-status/providers/together.ts +91 -0
- app/components/@settings/tabs/providers/service-status/providers/xai.ts +40 -0
- app/components/@settings/tabs/providers/service-status/types.ts +55 -0
- app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx +886 -0
- app/components/@settings/tabs/settings/SettingsTab.tsx +215 -0
- app/components/@settings/tabs/task-manager/TaskManagerTab.tsx +1265 -0
- app/components/@settings/tabs/update/UpdateTab.tsx +628 -0
- app/components/@settings/utils/animations.ts +41 -0
- app/components/@settings/utils/tab-helpers.ts +89 -0
- app/components/chat/APIKeyManager.tsx +195 -0
app/components/@settings/core/AvatarDropdown.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { useStore } from '@nanostores/react';
|
| 4 |
+
import { classNames } from '~/utils/classNames';
|
| 5 |
+
import { profileStore } from '~/lib/stores/profile';
|
| 6 |
+
import type { TabType, Profile } from './types';
|
| 7 |
+
|
| 8 |
+
const BetaLabel = () => (
|
| 9 |
+
<span className="px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20 text-[10px] font-medium text-purple-600 dark:text-purple-400 ml-2">
|
| 10 |
+
BETA
|
| 11 |
+
</span>
|
| 12 |
+
);
|
| 13 |
+
|
| 14 |
+
interface AvatarDropdownProps {
|
| 15 |
+
onSelectTab: (tab: TabType) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
| 19 |
+
const profile = useStore(profileStore) as Profile;
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<DropdownMenu.Root>
|
| 23 |
+
<DropdownMenu.Trigger asChild>
|
| 24 |
+
<motion.button
|
| 25 |
+
className="w-10 h-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
|
| 26 |
+
whileHover={{ scale: 1.02 }}
|
| 27 |
+
whileTap={{ scale: 0.98 }}
|
| 28 |
+
>
|
| 29 |
+
{profile?.avatar ? (
|
| 30 |
+
<img
|
| 31 |
+
src={profile.avatar}
|
| 32 |
+
alt={profile?.username || 'Profile'}
|
| 33 |
+
className="w-full h-full rounded-full object-cover"
|
| 34 |
+
loading="eager"
|
| 35 |
+
decoding="sync"
|
| 36 |
+
/>
|
| 37 |
+
) : (
|
| 38 |
+
<div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
| 39 |
+
<div className="i-ph:question w-6 h-6" />
|
| 40 |
+
</div>
|
| 41 |
+
)}
|
| 42 |
+
</motion.button>
|
| 43 |
+
</DropdownMenu.Trigger>
|
| 44 |
+
|
| 45 |
+
<DropdownMenu.Portal>
|
| 46 |
+
<DropdownMenu.Content
|
| 47 |
+
className={classNames(
|
| 48 |
+
'min-w-[240px] z-[250]',
|
| 49 |
+
'bg-white dark:bg-[#141414]',
|
| 50 |
+
'rounded-lg shadow-lg',
|
| 51 |
+
'border border-gray-200/50 dark:border-gray-800/50',
|
| 52 |
+
'animate-in fade-in-0 zoom-in-95',
|
| 53 |
+
'py-1',
|
| 54 |
+
)}
|
| 55 |
+
sideOffset={5}
|
| 56 |
+
align="end"
|
| 57 |
+
>
|
| 58 |
+
<div
|
| 59 |
+
className={classNames(
|
| 60 |
+
'px-4 py-3 flex items-center gap-3',
|
| 61 |
+
'border-b border-gray-200/50 dark:border-gray-800/50',
|
| 62 |
+
)}
|
| 63 |
+
>
|
| 64 |
+
<div className="w-10 h-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
|
| 65 |
+
{profile?.avatar ? (
|
| 66 |
+
<img
|
| 67 |
+
src={profile.avatar}
|
| 68 |
+
alt={profile?.username || 'Profile'}
|
| 69 |
+
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
| 70 |
+
loading="eager"
|
| 71 |
+
decoding="sync"
|
| 72 |
+
/>
|
| 73 |
+
) : (
|
| 74 |
+
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
| 75 |
+
<span className="relative -top-0.5">?</span>
|
| 76 |
+
</div>
|
| 77 |
+
)}
|
| 78 |
+
</div>
|
| 79 |
+
<div className="flex-1 min-w-0">
|
| 80 |
+
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
|
| 81 |
+
{profile?.username || 'Guest User'}
|
| 82 |
+
</div>
|
| 83 |
+
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<DropdownMenu.Item
|
| 88 |
+
className={classNames(
|
| 89 |
+
'flex items-center gap-2 px-4 py-2.5',
|
| 90 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
| 91 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
| 92 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
| 93 |
+
'cursor-pointer transition-all duration-200',
|
| 94 |
+
'outline-none',
|
| 95 |
+
'group',
|
| 96 |
+
)}
|
| 97 |
+
onClick={() => onSelectTab('profile')}
|
| 98 |
+
>
|
| 99 |
+
<div className="i-ph:user-circle w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
| 100 |
+
Edit Profile
|
| 101 |
+
</DropdownMenu.Item>
|
| 102 |
+
|
| 103 |
+
<DropdownMenu.Item
|
| 104 |
+
className={classNames(
|
| 105 |
+
'flex items-center gap-2 px-4 py-2.5',
|
| 106 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
| 107 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
| 108 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
| 109 |
+
'cursor-pointer transition-all duration-200',
|
| 110 |
+
'outline-none',
|
| 111 |
+
'group',
|
| 112 |
+
)}
|
| 113 |
+
onClick={() => onSelectTab('settings')}
|
| 114 |
+
>
|
| 115 |
+
<div className="i-ph:gear-six w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
| 116 |
+
Settings
|
| 117 |
+
</DropdownMenu.Item>
|
| 118 |
+
|
| 119 |
+
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
|
| 120 |
+
|
| 121 |
+
<DropdownMenu.Item
|
| 122 |
+
className={classNames(
|
| 123 |
+
'flex items-center gap-2 px-4 py-2.5',
|
| 124 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
| 125 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
| 126 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
| 127 |
+
'cursor-pointer transition-all duration-200',
|
| 128 |
+
'outline-none',
|
| 129 |
+
'group',
|
| 130 |
+
)}
|
| 131 |
+
onClick={() => onSelectTab('task-manager')}
|
| 132 |
+
>
|
| 133 |
+
<div className="i-ph:activity w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
| 134 |
+
Task Manager
|
| 135 |
+
<BetaLabel />
|
| 136 |
+
</DropdownMenu.Item>
|
| 137 |
+
|
| 138 |
+
<DropdownMenu.Item
|
| 139 |
+
className={classNames(
|
| 140 |
+
'flex items-center gap-2 px-4 py-2.5',
|
| 141 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
| 142 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
| 143 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
| 144 |
+
'cursor-pointer transition-all duration-200',
|
| 145 |
+
'outline-none',
|
| 146 |
+
'group',
|
| 147 |
+
)}
|
| 148 |
+
onClick={() => onSelectTab('service-status')}
|
| 149 |
+
>
|
| 150 |
+
<div className="i-ph:heartbeat w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
| 151 |
+
Service Status
|
| 152 |
+
<BetaLabel />
|
| 153 |
+
</DropdownMenu.Item>
|
| 154 |
+
</DropdownMenu.Content>
|
| 155 |
+
</DropdownMenu.Portal>
|
| 156 |
+
</DropdownMenu.Root>
|
| 157 |
+
);
|
| 158 |
+
};
|
app/components/@settings/core/ControlPanel.tsx
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useMemo } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import { useStore } from '@nanostores/react';
|
| 4 |
+
import { Switch } from '@radix-ui/react-switch';
|
| 5 |
+
import * as RadixDialog from '@radix-ui/react-dialog';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
|
| 8 |
+
import { TabTile } from '~/components/@settings/shared/components/TabTile';
|
| 9 |
+
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
|
| 10 |
+
import { useFeatures } from '~/lib/hooks/useFeatures';
|
| 11 |
+
import { useNotifications } from '~/lib/hooks/useNotifications';
|
| 12 |
+
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
|
| 13 |
+
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
|
| 14 |
+
import {
|
| 15 |
+
tabConfigurationStore,
|
| 16 |
+
developerModeStore,
|
| 17 |
+
setDeveloperMode,
|
| 18 |
+
resetTabConfiguration,
|
| 19 |
+
} from '~/lib/stores/settings';
|
| 20 |
+
import { profileStore } from '~/lib/stores/profile';
|
| 21 |
+
import type { TabType, TabVisibilityConfig, Profile } from './types';
|
| 22 |
+
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
|
| 23 |
+
import { DialogTitle } from '~/components/ui/Dialog';
|
| 24 |
+
import { AvatarDropdown } from './AvatarDropdown';
|
| 25 |
+
import BackgroundRays from '~/components/ui/BackgroundRays';
|
| 26 |
+
|
| 27 |
+
// Import all tab components
|
| 28 |
+
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
|
| 29 |
+
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
| 30 |
+
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
| 31 |
+
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
| 32 |
+
import DataTab from '~/components/@settings/tabs/data/DataTab';
|
| 33 |
+
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
| 34 |
+
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
| 35 |
+
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
| 36 |
+
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
| 37 |
+
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
|
| 38 |
+
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
|
| 39 |
+
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
|
| 40 |
+
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
|
| 41 |
+
|
| 42 |
+
interface ControlPanelProps {
|
| 43 |
+
open: boolean;
|
| 44 |
+
onClose: () => void;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
interface TabWithDevType extends TabVisibilityConfig {
|
| 48 |
+
isExtraDevTab?: boolean;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface ExtendedTabConfig extends TabVisibilityConfig {
|
| 52 |
+
isExtraDevTab?: boolean;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
interface BaseTabConfig {
|
| 56 |
+
id: TabType;
|
| 57 |
+
visible: boolean;
|
| 58 |
+
window: 'user' | 'developer';
|
| 59 |
+
order: number;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
interface AnimatedSwitchProps {
|
| 63 |
+
checked: boolean;
|
| 64 |
+
onCheckedChange: (checked: boolean) => void;
|
| 65 |
+
id: string;
|
| 66 |
+
label: string;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
| 70 |
+
profile: 'Manage your profile and account settings',
|
| 71 |
+
settings: 'Configure application preferences',
|
| 72 |
+
notifications: 'View and manage your notifications',
|
| 73 |
+
features: 'Explore new and upcoming features',
|
| 74 |
+
data: 'Manage your data and storage',
|
| 75 |
+
'cloud-providers': 'Configure cloud AI providers and models',
|
| 76 |
+
'local-providers': 'Configure local AI providers and models',
|
| 77 |
+
'service-status': 'Monitor cloud LLM service status',
|
| 78 |
+
connection: 'Check connection status and settings',
|
| 79 |
+
debug: 'Debug tools and system information',
|
| 80 |
+
'event-logs': 'View system events and logs',
|
| 81 |
+
update: 'Check for updates and release notes',
|
| 82 |
+
'task-manager': 'Monitor system resources and processes',
|
| 83 |
+
'tab-management': 'Configure visible tabs and their order',
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
// Beta status for experimental features
|
| 87 |
+
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
| 88 |
+
|
| 89 |
+
const BetaLabel = () => (
|
| 90 |
+
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
|
| 91 |
+
<span className="text-[10px] font-medium text-purple-600 dark:text-purple-400">BETA</span>
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => {
|
| 96 |
+
return (
|
| 97 |
+
<div className="flex items-center gap-2">
|
| 98 |
+
<Switch
|
| 99 |
+
id={id}
|
| 100 |
+
checked={checked}
|
| 101 |
+
onCheckedChange={onCheckedChange}
|
| 102 |
+
className={classNames(
|
| 103 |
+
'relative inline-flex h-6 w-11 items-center rounded-full',
|
| 104 |
+
'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]',
|
| 105 |
+
'bg-gray-200 dark:bg-gray-700',
|
| 106 |
+
'data-[state=checked]:bg-purple-500',
|
| 107 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
|
| 108 |
+
'cursor-pointer',
|
| 109 |
+
'group',
|
| 110 |
+
)}
|
| 111 |
+
>
|
| 112 |
+
<motion.span
|
| 113 |
+
className={classNames(
|
| 114 |
+
'absolute left-[2px] top-[2px]',
|
| 115 |
+
'inline-block h-5 w-5 rounded-full',
|
| 116 |
+
'bg-white shadow-lg',
|
| 117 |
+
'transition-shadow duration-300',
|
| 118 |
+
'group-hover:shadow-md group-active:shadow-sm',
|
| 119 |
+
'group-hover:scale-95 group-active:scale-90',
|
| 120 |
+
)}
|
| 121 |
+
initial={false}
|
| 122 |
+
transition={{
|
| 123 |
+
type: 'spring',
|
| 124 |
+
stiffness: 500,
|
| 125 |
+
damping: 30,
|
| 126 |
+
duration: 0.2,
|
| 127 |
+
}}
|
| 128 |
+
animate={{
|
| 129 |
+
x: checked ? '1.25rem' : '0rem',
|
| 130 |
+
}}
|
| 131 |
+
>
|
| 132 |
+
<motion.div
|
| 133 |
+
className="absolute inset-0 rounded-full bg-white"
|
| 134 |
+
initial={false}
|
| 135 |
+
animate={{
|
| 136 |
+
scale: checked ? 1 : 0.8,
|
| 137 |
+
}}
|
| 138 |
+
transition={{ duration: 0.2 }}
|
| 139 |
+
/>
|
| 140 |
+
</motion.span>
|
| 141 |
+
<span className="sr-only">Toggle {label}</span>
|
| 142 |
+
</Switch>
|
| 143 |
+
<div className="flex items-center gap-2">
|
| 144 |
+
<label
|
| 145 |
+
htmlFor={id}
|
| 146 |
+
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
|
| 147 |
+
>
|
| 148 |
+
{label}
|
| 149 |
+
</label>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
);
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
| 156 |
+
// State
|
| 157 |
+
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
| 158 |
+
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
| 159 |
+
const [showTabManagement, setShowTabManagement] = useState(false);
|
| 160 |
+
|
| 161 |
+
// Store values
|
| 162 |
+
const tabConfiguration = useStore(tabConfigurationStore);
|
| 163 |
+
const developerMode = useStore(developerModeStore);
|
| 164 |
+
const profile = useStore(profileStore) as Profile;
|
| 165 |
+
|
| 166 |
+
// Status hooks
|
| 167 |
+
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
|
| 168 |
+
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
|
| 169 |
+
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
|
| 170 |
+
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
|
| 171 |
+
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
| 172 |
+
|
| 173 |
+
// Memoize the base tab configurations to avoid recalculation
|
| 174 |
+
const baseTabConfig = useMemo(() => {
|
| 175 |
+
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
|
| 176 |
+
}, []);
|
| 177 |
+
|
| 178 |
+
// Add visibleTabs logic using useMemo with optimized calculations
|
| 179 |
+
const visibleTabs = useMemo(() => {
|
| 180 |
+
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
| 181 |
+
console.warn('Invalid tab configuration, resetting to defaults');
|
| 182 |
+
resetTabConfiguration();
|
| 183 |
+
|
| 184 |
+
return [];
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const notificationsDisabled = profile?.preferences?.notifications === false;
|
| 188 |
+
|
| 189 |
+
// In developer mode, show ALL tabs without restrictions
|
| 190 |
+
if (developerMode) {
|
| 191 |
+
const seenTabs = new Set<TabType>();
|
| 192 |
+
const devTabs: ExtendedTabConfig[] = [];
|
| 193 |
+
|
| 194 |
+
// Process tabs in order of priority: developer, user, default
|
| 195 |
+
const processTab = (tab: BaseTabConfig) => {
|
| 196 |
+
if (!seenTabs.has(tab.id)) {
|
| 197 |
+
seenTabs.add(tab.id);
|
| 198 |
+
devTabs.push({
|
| 199 |
+
id: tab.id,
|
| 200 |
+
visible: true,
|
| 201 |
+
window: 'developer',
|
| 202 |
+
order: tab.order || devTabs.length,
|
| 203 |
+
});
|
| 204 |
+
}
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
// Process tabs in priority order
|
| 208 |
+
tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
|
| 209 |
+
tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
|
| 210 |
+
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
|
| 211 |
+
|
| 212 |
+
// Add Tab Management tile
|
| 213 |
+
devTabs.push({
|
| 214 |
+
id: 'tab-management' as TabType,
|
| 215 |
+
visible: true,
|
| 216 |
+
window: 'developer',
|
| 217 |
+
order: devTabs.length,
|
| 218 |
+
isExtraDevTab: true,
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
return devTabs.sort((a, b) => a.order - b.order);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Optimize user mode tab filtering
|
| 225 |
+
return tabConfiguration.userTabs
|
| 226 |
+
.filter((tab) => {
|
| 227 |
+
if (!tab?.id) {
|
| 228 |
+
return false;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
if (tab.id === 'notifications' && notificationsDisabled) {
|
| 232 |
+
return false;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
return tab.visible && tab.window === 'user';
|
| 236 |
+
})
|
| 237 |
+
.sort((a, b) => a.order - b.order);
|
| 238 |
+
}, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]);
|
| 239 |
+
|
| 240 |
+
// Optimize animation performance with layout animations
|
| 241 |
+
const gridLayoutVariants = {
|
| 242 |
+
hidden: { opacity: 0 },
|
| 243 |
+
visible: {
|
| 244 |
+
opacity: 1,
|
| 245 |
+
transition: {
|
| 246 |
+
staggerChildren: 0.05,
|
| 247 |
+
delayChildren: 0.1,
|
| 248 |
+
},
|
| 249 |
+
},
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
const itemVariants = {
|
| 253 |
+
hidden: { opacity: 0, scale: 0.8 },
|
| 254 |
+
visible: {
|
| 255 |
+
opacity: 1,
|
| 256 |
+
scale: 1,
|
| 257 |
+
transition: {
|
| 258 |
+
type: 'spring',
|
| 259 |
+
stiffness: 200,
|
| 260 |
+
damping: 20,
|
| 261 |
+
mass: 0.6,
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
// Reset to default view when modal opens/closes
|
| 267 |
+
useEffect(() => {
|
| 268 |
+
if (!open) {
|
| 269 |
+
// Reset when closing
|
| 270 |
+
setActiveTab(null);
|
| 271 |
+
setLoadingTab(null);
|
| 272 |
+
setShowTabManagement(false);
|
| 273 |
+
} else {
|
| 274 |
+
// When opening, set to null to show the main view
|
| 275 |
+
setActiveTab(null);
|
| 276 |
+
}
|
| 277 |
+
}, [open]);
|
| 278 |
+
|
| 279 |
+
// Handle closing
|
| 280 |
+
const handleClose = () => {
|
| 281 |
+
setActiveTab(null);
|
| 282 |
+
setLoadingTab(null);
|
| 283 |
+
setShowTabManagement(false);
|
| 284 |
+
onClose();
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
// Handlers
|
| 288 |
+
const handleBack = () => {
|
| 289 |
+
if (showTabManagement) {
|
| 290 |
+
setShowTabManagement(false);
|
| 291 |
+
} else if (activeTab) {
|
| 292 |
+
setActiveTab(null);
|
| 293 |
+
}
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
const handleDeveloperModeChange = (checked: boolean) => {
|
| 297 |
+
console.log('Developer mode changed:', checked);
|
| 298 |
+
setDeveloperMode(checked);
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
// Add effect to log developer mode changes
|
| 302 |
+
useEffect(() => {
|
| 303 |
+
console.log('Current developer mode:', developerMode);
|
| 304 |
+
}, [developerMode]);
|
| 305 |
+
|
| 306 |
+
const getTabComponent = (tabId: TabType | 'tab-management') => {
|
| 307 |
+
if (tabId === 'tab-management') {
|
| 308 |
+
return <TabManagement />;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
switch (tabId) {
|
| 312 |
+
case 'profile':
|
| 313 |
+
return <ProfileTab />;
|
| 314 |
+
case 'settings':
|
| 315 |
+
return <SettingsTab />;
|
| 316 |
+
case 'notifications':
|
| 317 |
+
return <NotificationsTab />;
|
| 318 |
+
case 'features':
|
| 319 |
+
return <FeaturesTab />;
|
| 320 |
+
case 'data':
|
| 321 |
+
return <DataTab />;
|
| 322 |
+
case 'cloud-providers':
|
| 323 |
+
return <CloudProvidersTab />;
|
| 324 |
+
case 'local-providers':
|
| 325 |
+
return <LocalProvidersTab />;
|
| 326 |
+
case 'connection':
|
| 327 |
+
return <ConnectionsTab />;
|
| 328 |
+
case 'debug':
|
| 329 |
+
return <DebugTab />;
|
| 330 |
+
case 'event-logs':
|
| 331 |
+
return <EventLogsTab />;
|
| 332 |
+
case 'update':
|
| 333 |
+
return <UpdateTab />;
|
| 334 |
+
case 'task-manager':
|
| 335 |
+
return <TaskManagerTab />;
|
| 336 |
+
case 'service-status':
|
| 337 |
+
return <ServiceStatusTab />;
|
| 338 |
+
default:
|
| 339 |
+
return null;
|
| 340 |
+
}
|
| 341 |
+
};
|
| 342 |
+
|
| 343 |
+
const getTabUpdateStatus = (tabId: TabType): boolean => {
|
| 344 |
+
switch (tabId) {
|
| 345 |
+
case 'update':
|
| 346 |
+
return hasUpdate;
|
| 347 |
+
case 'features':
|
| 348 |
+
return hasNewFeatures;
|
| 349 |
+
case 'notifications':
|
| 350 |
+
return hasUnreadNotifications;
|
| 351 |
+
case 'connection':
|
| 352 |
+
return hasConnectionIssues;
|
| 353 |
+
case 'debug':
|
| 354 |
+
return hasActiveWarnings;
|
| 355 |
+
default:
|
| 356 |
+
return false;
|
| 357 |
+
}
|
| 358 |
+
};
|
| 359 |
+
|
| 360 |
+
const getStatusMessage = (tabId: TabType): string => {
|
| 361 |
+
switch (tabId) {
|
| 362 |
+
case 'update':
|
| 363 |
+
return `New update available (v${currentVersion})`;
|
| 364 |
+
case 'features':
|
| 365 |
+
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
|
| 366 |
+
case 'notifications':
|
| 367 |
+
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
|
| 368 |
+
case 'connection':
|
| 369 |
+
return currentIssue === 'disconnected'
|
| 370 |
+
? 'Connection lost'
|
| 371 |
+
: currentIssue === 'high-latency'
|
| 372 |
+
? 'High latency detected'
|
| 373 |
+
: 'Connection issues detected';
|
| 374 |
+
case 'debug': {
|
| 375 |
+
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
|
| 376 |
+
const errors = activeIssues.filter((i) => i.type === 'error').length;
|
| 377 |
+
|
| 378 |
+
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
|
| 379 |
+
}
|
| 380 |
+
default:
|
| 381 |
+
return '';
|
| 382 |
+
}
|
| 383 |
+
};
|
| 384 |
+
|
| 385 |
+
const handleTabClick = (tabId: TabType) => {
|
| 386 |
+
setLoadingTab(tabId);
|
| 387 |
+
setActiveTab(tabId);
|
| 388 |
+
setShowTabManagement(false);
|
| 389 |
+
|
| 390 |
+
// Acknowledge notifications based on tab
|
| 391 |
+
switch (tabId) {
|
| 392 |
+
case 'update':
|
| 393 |
+
acknowledgeUpdate();
|
| 394 |
+
break;
|
| 395 |
+
case 'features':
|
| 396 |
+
acknowledgeAllFeatures();
|
| 397 |
+
break;
|
| 398 |
+
case 'notifications':
|
| 399 |
+
markAllAsRead();
|
| 400 |
+
break;
|
| 401 |
+
case 'connection':
|
| 402 |
+
acknowledgeIssue();
|
| 403 |
+
break;
|
| 404 |
+
case 'debug':
|
| 405 |
+
acknowledgeAllIssues();
|
| 406 |
+
break;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// Clear loading state after a delay
|
| 410 |
+
setTimeout(() => setLoadingTab(null), 500);
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
return (
|
| 414 |
+
<RadixDialog.Root open={open}>
|
| 415 |
+
<RadixDialog.Portal>
|
| 416 |
+
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
| 417 |
+
<RadixDialog.Overlay asChild>
|
| 418 |
+
<motion.div
|
| 419 |
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
| 420 |
+
initial={{ opacity: 0 }}
|
| 421 |
+
animate={{ opacity: 1 }}
|
| 422 |
+
exit={{ opacity: 0 }}
|
| 423 |
+
transition={{ duration: 0.2 }}
|
| 424 |
+
/>
|
| 425 |
+
</RadixDialog.Overlay>
|
| 426 |
+
|
| 427 |
+
<RadixDialog.Content
|
| 428 |
+
aria-describedby={undefined}
|
| 429 |
+
onEscapeKeyDown={handleClose}
|
| 430 |
+
onPointerDownOutside={handleClose}
|
| 431 |
+
className="relative z-[101]"
|
| 432 |
+
>
|
| 433 |
+
<motion.div
|
| 434 |
+
className={classNames(
|
| 435 |
+
'w-[1200px] h-[90vh]',
|
| 436 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 437 |
+
'rounded-2xl shadow-2xl',
|
| 438 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 439 |
+
'flex flex-col overflow-hidden',
|
| 440 |
+
'relative',
|
| 441 |
+
)}
|
| 442 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 443 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 444 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 445 |
+
transition={{ duration: 0.2 }}
|
| 446 |
+
>
|
| 447 |
+
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
| 448 |
+
<BackgroundRays />
|
| 449 |
+
</div>
|
| 450 |
+
<div className="relative z-10 flex flex-col h-full">
|
| 451 |
+
{/* Header */}
|
| 452 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
| 453 |
+
<div className="flex items-center space-x-4">
|
| 454 |
+
{(activeTab || showTabManagement) && (
|
| 455 |
+
<button
|
| 456 |
+
onClick={handleBack}
|
| 457 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
| 458 |
+
>
|
| 459 |
+
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
| 460 |
+
</button>
|
| 461 |
+
)}
|
| 462 |
+
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
|
| 463 |
+
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
|
| 464 |
+
</DialogTitle>
|
| 465 |
+
</div>
|
| 466 |
+
|
| 467 |
+
<div className="flex items-center gap-6">
|
| 468 |
+
{/* Mode Toggle */}
|
| 469 |
+
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
|
| 470 |
+
<AnimatedSwitch
|
| 471 |
+
id="developer-mode"
|
| 472 |
+
checked={developerMode}
|
| 473 |
+
onCheckedChange={handleDeveloperModeChange}
|
| 474 |
+
label={developerMode ? 'Developer Mode' : 'User Mode'}
|
| 475 |
+
/>
|
| 476 |
+
</div>
|
| 477 |
+
|
| 478 |
+
{/* Avatar and Dropdown */}
|
| 479 |
+
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
| 480 |
+
<AvatarDropdown onSelectTab={handleTabClick} />
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
{/* Close Button */}
|
| 484 |
+
<button
|
| 485 |
+
onClick={handleClose}
|
| 486 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
| 487 |
+
>
|
| 488 |
+
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
| 489 |
+
</button>
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
{/* Content */}
|
| 494 |
+
<div
|
| 495 |
+
className={classNames(
|
| 496 |
+
'flex-1',
|
| 497 |
+
'overflow-y-auto',
|
| 498 |
+
'hover:overflow-y-auto',
|
| 499 |
+
'scrollbar scrollbar-w-2',
|
| 500 |
+
'scrollbar-track-transparent',
|
| 501 |
+
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
| 502 |
+
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
| 503 |
+
'will-change-scroll',
|
| 504 |
+
'touch-auto',
|
| 505 |
+
)}
|
| 506 |
+
>
|
| 507 |
+
<motion.div
|
| 508 |
+
key={activeTab || 'home'}
|
| 509 |
+
initial={{ opacity: 0 }}
|
| 510 |
+
animate={{ opacity: 1 }}
|
| 511 |
+
exit={{ opacity: 0 }}
|
| 512 |
+
transition={{ duration: 0.2 }}
|
| 513 |
+
className="p-6"
|
| 514 |
+
>
|
| 515 |
+
{showTabManagement ? (
|
| 516 |
+
<TabManagement />
|
| 517 |
+
) : activeTab ? (
|
| 518 |
+
getTabComponent(activeTab)
|
| 519 |
+
) : (
|
| 520 |
+
<motion.div
|
| 521 |
+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
| 522 |
+
variants={gridLayoutVariants}
|
| 523 |
+
initial="hidden"
|
| 524 |
+
animate="visible"
|
| 525 |
+
>
|
| 526 |
+
<AnimatePresence mode="popLayout">
|
| 527 |
+
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
| 528 |
+
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
| 529 |
+
<TabTile
|
| 530 |
+
tab={tab}
|
| 531 |
+
onClick={() => handleTabClick(tab.id as TabType)}
|
| 532 |
+
isActive={activeTab === tab.id}
|
| 533 |
+
hasUpdate={getTabUpdateStatus(tab.id)}
|
| 534 |
+
statusMessage={getStatusMessage(tab.id)}
|
| 535 |
+
description={TAB_DESCRIPTIONS[tab.id]}
|
| 536 |
+
isLoading={loadingTab === tab.id}
|
| 537 |
+
className="h-full relative"
|
| 538 |
+
>
|
| 539 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
| 540 |
+
</TabTile>
|
| 541 |
+
</motion.div>
|
| 542 |
+
))}
|
| 543 |
+
</AnimatePresence>
|
| 544 |
+
</motion.div>
|
| 545 |
+
)}
|
| 546 |
+
</motion.div>
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
</motion.div>
|
| 550 |
+
</RadixDialog.Content>
|
| 551 |
+
</div>
|
| 552 |
+
</RadixDialog.Portal>
|
| 553 |
+
</RadixDialog.Root>
|
| 554 |
+
);
|
| 555 |
+
};
|
app/components/@settings/core/constants.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { TabType } from './types';
|
| 2 |
+
|
| 3 |
+
export const TAB_ICONS: Record<TabType, string> = {
|
| 4 |
+
profile: 'i-ph:user-circle-fill',
|
| 5 |
+
settings: 'i-ph:gear-six-fill',
|
| 6 |
+
notifications: 'i-ph:bell-fill',
|
| 7 |
+
features: 'i-ph:star-fill',
|
| 8 |
+
data: 'i-ph:database-fill',
|
| 9 |
+
'cloud-providers': 'i-ph:cloud-fill',
|
| 10 |
+
'local-providers': 'i-ph:desktop-fill',
|
| 11 |
+
'service-status': 'i-ph:activity-bold',
|
| 12 |
+
connection: 'i-ph:wifi-high-fill',
|
| 13 |
+
debug: 'i-ph:bug-fill',
|
| 14 |
+
'event-logs': 'i-ph:list-bullets-fill',
|
| 15 |
+
update: 'i-ph:arrow-clockwise-fill',
|
| 16 |
+
'task-manager': 'i-ph:chart-line-fill',
|
| 17 |
+
'tab-management': 'i-ph:squares-four-fill',
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export const TAB_LABELS: Record<TabType, string> = {
|
| 21 |
+
profile: 'Profile',
|
| 22 |
+
settings: 'Settings',
|
| 23 |
+
notifications: 'Notifications',
|
| 24 |
+
features: 'Features',
|
| 25 |
+
data: 'Data Management',
|
| 26 |
+
'cloud-providers': 'Cloud Providers',
|
| 27 |
+
'local-providers': 'Local Providers',
|
| 28 |
+
'service-status': 'Service Status',
|
| 29 |
+
connection: 'Connection',
|
| 30 |
+
debug: 'Debug',
|
| 31 |
+
'event-logs': 'Event Logs',
|
| 32 |
+
update: 'Updates',
|
| 33 |
+
'task-manager': 'Task Manager',
|
| 34 |
+
'tab-management': 'Tab Management',
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
| 38 |
+
profile: 'Manage your profile and account settings',
|
| 39 |
+
settings: 'Configure application preferences',
|
| 40 |
+
notifications: 'View and manage your notifications',
|
| 41 |
+
features: 'Explore new and upcoming features',
|
| 42 |
+
data: 'Manage your data and storage',
|
| 43 |
+
'cloud-providers': 'Configure cloud AI providers and models',
|
| 44 |
+
'local-providers': 'Configure local AI providers and models',
|
| 45 |
+
'service-status': 'Monitor cloud LLM service status',
|
| 46 |
+
connection: 'Check connection status and settings',
|
| 47 |
+
debug: 'Debug tools and system information',
|
| 48 |
+
'event-logs': 'View system events and logs',
|
| 49 |
+
update: 'Check for updates and release notes',
|
| 50 |
+
'task-manager': 'Monitor system resources and processes',
|
| 51 |
+
'tab-management': 'Configure visible tabs and their order',
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
export const DEFAULT_TAB_CONFIG = [
|
| 55 |
+
// User Window Tabs (Always visible by default)
|
| 56 |
+
{ id: 'features', visible: true, window: 'user' as const, order: 0 },
|
| 57 |
+
{ id: 'data', visible: true, window: 'user' as const, order: 1 },
|
| 58 |
+
{ id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 },
|
| 59 |
+
{ id: 'local-providers', visible: true, window: 'user' as const, order: 3 },
|
| 60 |
+
{ id: 'connection', visible: true, window: 'user' as const, order: 4 },
|
| 61 |
+
{ id: 'notifications', visible: true, window: 'user' as const, order: 5 },
|
| 62 |
+
{ id: 'event-logs', visible: true, window: 'user' as const, order: 6 },
|
| 63 |
+
|
| 64 |
+
// User Window Tabs (In dropdown, initially hidden)
|
| 65 |
+
{ id: 'profile', visible: false, window: 'user' as const, order: 7 },
|
| 66 |
+
{ id: 'settings', visible: false, window: 'user' as const, order: 8 },
|
| 67 |
+
{ id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
|
| 68 |
+
{ id: 'service-status', visible: false, window: 'user' as const, order: 10 },
|
| 69 |
+
|
| 70 |
+
// User Window Tabs (Hidden, controlled by TaskManagerTab)
|
| 71 |
+
{ id: 'debug', visible: false, window: 'user' as const, order: 11 },
|
| 72 |
+
{ id: 'update', visible: false, window: 'user' as const, order: 12 },
|
| 73 |
+
|
| 74 |
+
// Developer Window Tabs (All visible by default)
|
| 75 |
+
{ id: 'features', visible: true, window: 'developer' as const, order: 0 },
|
| 76 |
+
{ id: 'data', visible: true, window: 'developer' as const, order: 1 },
|
| 77 |
+
{ id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 },
|
| 78 |
+
{ id: 'local-providers', visible: true, window: 'developer' as const, order: 3 },
|
| 79 |
+
{ id: 'connection', visible: true, window: 'developer' as const, order: 4 },
|
| 80 |
+
{ id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
|
| 81 |
+
{ id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
|
| 82 |
+
{ id: 'profile', visible: true, window: 'developer' as const, order: 7 },
|
| 83 |
+
{ id: 'settings', visible: true, window: 'developer' as const, order: 8 },
|
| 84 |
+
{ id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
|
| 85 |
+
{ id: 'service-status', visible: true, window: 'developer' as const, order: 10 },
|
| 86 |
+
{ id: 'debug', visible: true, window: 'developer' as const, order: 11 },
|
| 87 |
+
{ id: 'update', visible: true, window: 'developer' as const, order: 12 },
|
| 88 |
+
];
|
app/components/@settings/core/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react';
|
| 2 |
+
|
| 3 |
+
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
|
| 4 |
+
|
| 5 |
+
export type TabType =
|
| 6 |
+
| 'profile'
|
| 7 |
+
| 'settings'
|
| 8 |
+
| 'notifications'
|
| 9 |
+
| 'features'
|
| 10 |
+
| 'data'
|
| 11 |
+
| 'cloud-providers'
|
| 12 |
+
| 'local-providers'
|
| 13 |
+
| 'service-status'
|
| 14 |
+
| 'connection'
|
| 15 |
+
| 'debug'
|
| 16 |
+
| 'event-logs'
|
| 17 |
+
| 'update'
|
| 18 |
+
| 'task-manager'
|
| 19 |
+
| 'tab-management';
|
| 20 |
+
|
| 21 |
+
export type WindowType = 'user' | 'developer';
|
| 22 |
+
|
| 23 |
+
export interface UserProfile {
|
| 24 |
+
nickname: any;
|
| 25 |
+
name: string;
|
| 26 |
+
email: string;
|
| 27 |
+
avatar?: string;
|
| 28 |
+
theme: 'light' | 'dark' | 'system';
|
| 29 |
+
notifications: boolean;
|
| 30 |
+
password?: string;
|
| 31 |
+
bio?: string;
|
| 32 |
+
language: string;
|
| 33 |
+
timezone: string;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export interface SettingItem {
|
| 37 |
+
id: TabType;
|
| 38 |
+
label: string;
|
| 39 |
+
icon: string;
|
| 40 |
+
category: SettingCategory;
|
| 41 |
+
description?: string;
|
| 42 |
+
component: () => ReactNode;
|
| 43 |
+
badge?: string;
|
| 44 |
+
keywords?: string[];
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export interface TabVisibilityConfig {
|
| 48 |
+
id: TabType;
|
| 49 |
+
visible: boolean;
|
| 50 |
+
window: WindowType;
|
| 51 |
+
order: number;
|
| 52 |
+
isExtraDevTab?: boolean;
|
| 53 |
+
locked?: boolean;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface DevTabConfig extends TabVisibilityConfig {
|
| 57 |
+
window: 'developer';
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface UserTabConfig extends TabVisibilityConfig {
|
| 61 |
+
window: 'user';
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export interface TabWindowConfig {
|
| 65 |
+
userTabs: UserTabConfig[];
|
| 66 |
+
developerTabs: DevTabConfig[];
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export const TAB_LABELS: Record<TabType, string> = {
|
| 70 |
+
profile: 'Profile',
|
| 71 |
+
settings: 'Settings',
|
| 72 |
+
notifications: 'Notifications',
|
| 73 |
+
features: 'Features',
|
| 74 |
+
data: 'Data Management',
|
| 75 |
+
'cloud-providers': 'Cloud Providers',
|
| 76 |
+
'local-providers': 'Local Providers',
|
| 77 |
+
'service-status': 'Service Status',
|
| 78 |
+
connection: 'Connections',
|
| 79 |
+
debug: 'Debug',
|
| 80 |
+
'event-logs': 'Event Logs',
|
| 81 |
+
update: 'Updates',
|
| 82 |
+
'task-manager': 'Task Manager',
|
| 83 |
+
'tab-management': 'Tab Management',
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
export const categoryLabels: Record<SettingCategory, string> = {
|
| 87 |
+
profile: 'Profile & Account',
|
| 88 |
+
file_sharing: 'File Sharing',
|
| 89 |
+
connectivity: 'Connectivity',
|
| 90 |
+
system: 'System',
|
| 91 |
+
services: 'Services',
|
| 92 |
+
preferences: 'Preferences',
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
export const categoryIcons: Record<SettingCategory, string> = {
|
| 96 |
+
profile: 'i-ph:user-circle',
|
| 97 |
+
file_sharing: 'i-ph:folder-simple',
|
| 98 |
+
connectivity: 'i-ph:wifi-high',
|
| 99 |
+
system: 'i-ph:gear',
|
| 100 |
+
services: 'i-ph:cube',
|
| 101 |
+
preferences: 'i-ph:sliders',
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
export interface Profile {
|
| 105 |
+
username?: string;
|
| 106 |
+
bio?: string;
|
| 107 |
+
avatar?: string;
|
| 108 |
+
preferences?: {
|
| 109 |
+
notifications?: boolean;
|
| 110 |
+
theme?: 'light' | 'dark' | 'system';
|
| 111 |
+
language?: string;
|
| 112 |
+
timezone?: string;
|
| 113 |
+
};
|
| 114 |
+
}
|
app/components/@settings/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Core exports
|
| 2 |
+
export { ControlPanel } from './core/ControlPanel';
|
| 3 |
+
export type { TabType, TabVisibilityConfig } from './core/types';
|
| 4 |
+
|
| 5 |
+
// Constants
|
| 6 |
+
export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants';
|
| 7 |
+
|
| 8 |
+
// Shared components
|
| 9 |
+
export { TabTile } from './shared/components/TabTile';
|
| 10 |
+
export { TabManagement } from './shared/components/TabManagement';
|
| 11 |
+
|
| 12 |
+
// Utils
|
| 13 |
+
export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
|
| 14 |
+
export * from './utils/animations';
|
app/components/@settings/shared/components/DraggableTabList.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useDrag, useDrop } from 'react-dnd';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
| 5 |
+
import { TAB_LABELS } from '~/components/@settings/core/types';
|
| 6 |
+
import { Switch } from '~/components/ui/Switch';
|
| 7 |
+
|
| 8 |
+
interface DraggableTabListProps {
|
| 9 |
+
tabs: TabVisibilityConfig[];
|
| 10 |
+
onReorder: (tabs: TabVisibilityConfig[]) => void;
|
| 11 |
+
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
| 12 |
+
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
| 13 |
+
showControls?: boolean;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface DraggableTabItemProps {
|
| 17 |
+
tab: TabVisibilityConfig;
|
| 18 |
+
index: number;
|
| 19 |
+
moveTab: (dragIndex: number, hoverIndex: number) => void;
|
| 20 |
+
showControls?: boolean;
|
| 21 |
+
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
| 22 |
+
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
interface DragItem {
|
| 26 |
+
type: string;
|
| 27 |
+
index: number;
|
| 28 |
+
id: string;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const DraggableTabItem = ({
|
| 32 |
+
tab,
|
| 33 |
+
index,
|
| 34 |
+
moveTab,
|
| 35 |
+
showControls,
|
| 36 |
+
onWindowChange,
|
| 37 |
+
onVisibilityChange,
|
| 38 |
+
}: DraggableTabItemProps) => {
|
| 39 |
+
const [{ isDragging }, dragRef] = useDrag({
|
| 40 |
+
type: 'tab',
|
| 41 |
+
item: { type: 'tab', index, id: tab.id },
|
| 42 |
+
collect: (monitor) => ({
|
| 43 |
+
isDragging: monitor.isDragging(),
|
| 44 |
+
}),
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
const [, dropRef] = useDrop({
|
| 48 |
+
accept: 'tab',
|
| 49 |
+
hover: (item: DragItem, monitor) => {
|
| 50 |
+
if (!monitor.isOver({ shallow: true })) {
|
| 51 |
+
return;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (item.index === index) {
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (item.id === tab.id) {
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
moveTab(item.index, index);
|
| 63 |
+
item.index = index;
|
| 64 |
+
},
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
const ref = (node: HTMLDivElement | null) => {
|
| 68 |
+
dragRef(node);
|
| 69 |
+
dropRef(node);
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<motion.div
|
| 74 |
+
ref={ref}
|
| 75 |
+
initial={false}
|
| 76 |
+
animate={{
|
| 77 |
+
scale: isDragging ? 1.02 : 1,
|
| 78 |
+
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
|
| 79 |
+
}}
|
| 80 |
+
className={classNames(
|
| 81 |
+
'flex items-center justify-between p-4 rounded-lg',
|
| 82 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 83 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 84 |
+
isDragging ? 'z-50' : '',
|
| 85 |
+
)}
|
| 86 |
+
>
|
| 87 |
+
<div className="flex items-center gap-4">
|
| 88 |
+
<div className="cursor-grab">
|
| 89 |
+
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
|
| 90 |
+
</div>
|
| 91 |
+
<div>
|
| 92 |
+
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
|
| 93 |
+
{showControls && (
|
| 94 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 95 |
+
Order: {tab.order}, Window: {tab.window}
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
{showControls && !tab.locked && (
|
| 101 |
+
<div className="flex items-center gap-4">
|
| 102 |
+
<div className="flex items-center gap-2">
|
| 103 |
+
<Switch
|
| 104 |
+
checked={tab.visible}
|
| 105 |
+
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
|
| 106 |
+
className="data-[state=checked]:bg-purple-500"
|
| 107 |
+
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
|
| 108 |
+
/>
|
| 109 |
+
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
|
| 110 |
+
</div>
|
| 111 |
+
<div className="flex items-center gap-2">
|
| 112 |
+
<label className="text-sm text-bolt-elements-textSecondary">User</label>
|
| 113 |
+
<Switch
|
| 114 |
+
checked={tab.window === 'developer'}
|
| 115 |
+
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
|
| 116 |
+
className="data-[state=checked]:bg-purple-500"
|
| 117 |
+
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
|
| 118 |
+
/>
|
| 119 |
+
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
)}
|
| 123 |
+
</motion.div>
|
| 124 |
+
);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
export const DraggableTabList = ({
|
| 128 |
+
tabs,
|
| 129 |
+
onReorder,
|
| 130 |
+
onWindowChange,
|
| 131 |
+
onVisibilityChange,
|
| 132 |
+
showControls = false,
|
| 133 |
+
}: DraggableTabListProps) => {
|
| 134 |
+
const moveTab = (dragIndex: number, hoverIndex: number) => {
|
| 135 |
+
const items = Array.from(tabs);
|
| 136 |
+
const [reorderedItem] = items.splice(dragIndex, 1);
|
| 137 |
+
items.splice(hoverIndex, 0, reorderedItem);
|
| 138 |
+
|
| 139 |
+
// Update order numbers based on position
|
| 140 |
+
const reorderedTabs = items.map((tab, index) => ({
|
| 141 |
+
...tab,
|
| 142 |
+
order: index + 1,
|
| 143 |
+
}));
|
| 144 |
+
|
| 145 |
+
onReorder(reorderedTabs);
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
return (
|
| 149 |
+
<div className="space-y-2">
|
| 150 |
+
{tabs.map((tab, index) => (
|
| 151 |
+
<DraggableTabItem
|
| 152 |
+
key={tab.id}
|
| 153 |
+
tab={tab}
|
| 154 |
+
index={index}
|
| 155 |
+
moveTab={moveTab}
|
| 156 |
+
showControls={showControls}
|
| 157 |
+
onWindowChange={onWindowChange}
|
| 158 |
+
onVisibilityChange={onVisibilityChange}
|
| 159 |
+
/>
|
| 160 |
+
))}
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
};
|
app/components/@settings/shared/components/TabManagement.tsx
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { useStore } from '@nanostores/react';
|
| 4 |
+
import { Switch } from '~/components/ui/Switch';
|
| 5 |
+
import { classNames } from '~/utils/classNames';
|
| 6 |
+
import { tabConfigurationStore } from '~/lib/stores/settings';
|
| 7 |
+
import { TAB_LABELS } from '~/components/@settings/core/constants';
|
| 8 |
+
import type { TabType } from '~/components/@settings/core/types';
|
| 9 |
+
import { toast } from 'react-toastify';
|
| 10 |
+
import { TbLayoutGrid } from 'react-icons/tb';
|
| 11 |
+
import { useSettingsStore } from '~/lib/stores/settings';
|
| 12 |
+
|
| 13 |
+
// Define tab icons mapping
|
| 14 |
+
const TAB_ICONS: Record<TabType, string> = {
|
| 15 |
+
profile: 'i-ph:user-circle-fill',
|
| 16 |
+
settings: 'i-ph:gear-six-fill',
|
| 17 |
+
notifications: 'i-ph:bell-fill',
|
| 18 |
+
features: 'i-ph:star-fill',
|
| 19 |
+
data: 'i-ph:database-fill',
|
| 20 |
+
'cloud-providers': 'i-ph:cloud-fill',
|
| 21 |
+
'local-providers': 'i-ph:desktop-fill',
|
| 22 |
+
'service-status': 'i-ph:activity-fill',
|
| 23 |
+
connection: 'i-ph:wifi-high-fill',
|
| 24 |
+
debug: 'i-ph:bug-fill',
|
| 25 |
+
'event-logs': 'i-ph:list-bullets-fill',
|
| 26 |
+
update: 'i-ph:arrow-clockwise-fill',
|
| 27 |
+
'task-manager': 'i-ph:chart-line-fill',
|
| 28 |
+
'tab-management': 'i-ph:squares-four-fill',
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// Define which tabs are default in user mode
|
| 32 |
+
const DEFAULT_USER_TABS: TabType[] = [
|
| 33 |
+
'features',
|
| 34 |
+
'data',
|
| 35 |
+
'cloud-providers',
|
| 36 |
+
'local-providers',
|
| 37 |
+
'connection',
|
| 38 |
+
'notifications',
|
| 39 |
+
'event-logs',
|
| 40 |
+
];
|
| 41 |
+
|
| 42 |
+
// Define which tabs can be added to user mode
|
| 43 |
+
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
|
| 44 |
+
|
| 45 |
+
// All available tabs for user mode
|
| 46 |
+
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
|
| 47 |
+
|
| 48 |
+
// Define which tabs are beta
|
| 49 |
+
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
| 50 |
+
|
| 51 |
+
// Beta label component
|
| 52 |
+
const BetaLabel = () => (
|
| 53 |
+
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-purple-500/10 text-purple-500 font-medium">BETA</span>
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
export const TabManagement = () => {
|
| 57 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 58 |
+
const tabConfiguration = useStore(tabConfigurationStore);
|
| 59 |
+
const { setSelectedTab } = useSettingsStore();
|
| 60 |
+
|
| 61 |
+
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
|
| 62 |
+
// Get current tab configuration
|
| 63 |
+
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
|
| 64 |
+
|
| 65 |
+
// If tab doesn't exist in configuration, create it
|
| 66 |
+
if (!currentTab) {
|
| 67 |
+
const newTab = {
|
| 68 |
+
id: tabId,
|
| 69 |
+
visible: checked,
|
| 70 |
+
window: 'user' as const,
|
| 71 |
+
order: tabConfiguration.userTabs.length,
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const updatedTabs = [...tabConfiguration.userTabs, newTab];
|
| 75 |
+
|
| 76 |
+
tabConfigurationStore.set({
|
| 77 |
+
...tabConfiguration,
|
| 78 |
+
userTabs: updatedTabs,
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
| 82 |
+
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Check if tab can be enabled in user mode
|
| 87 |
+
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
|
| 88 |
+
|
| 89 |
+
if (!canBeEnabled && checked) {
|
| 90 |
+
toast.error('This tab cannot be enabled in user mode');
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Update tab visibility
|
| 95 |
+
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
|
| 96 |
+
if (tab.id === tabId) {
|
| 97 |
+
return { ...tab, visible: checked };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return tab;
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// Update store
|
| 104 |
+
tabConfigurationStore.set({
|
| 105 |
+
...tabConfiguration,
|
| 106 |
+
userTabs: updatedTabs,
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
// Show success message
|
| 110 |
+
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
// Create a map of existing tab configurations
|
| 114 |
+
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
|
| 115 |
+
|
| 116 |
+
// Generate the complete list of tabs, including those not in the configuration
|
| 117 |
+
const allTabs = ALL_USER_TABS.map((tabId) => {
|
| 118 |
+
return (
|
| 119 |
+
tabConfigMap.get(tabId) || {
|
| 120 |
+
id: tabId,
|
| 121 |
+
visible: false,
|
| 122 |
+
window: 'user' as const,
|
| 123 |
+
order: -1,
|
| 124 |
+
}
|
| 125 |
+
);
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
// Filter tabs based on search query
|
| 129 |
+
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
|
| 130 |
+
|
| 131 |
+
useEffect(() => {
|
| 132 |
+
// Reset to first tab when component unmounts
|
| 133 |
+
return () => {
|
| 134 |
+
setSelectedTab('user'); // Reset to user tab when unmounting
|
| 135 |
+
};
|
| 136 |
+
}, [setSelectedTab]);
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div className="space-y-6">
|
| 140 |
+
<motion.div
|
| 141 |
+
className="space-y-4"
|
| 142 |
+
initial={{ opacity: 0, y: 20 }}
|
| 143 |
+
animate={{ opacity: 1, y: 0 }}
|
| 144 |
+
transition={{ duration: 0.3 }}
|
| 145 |
+
>
|
| 146 |
+
{/* Header */}
|
| 147 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
| 148 |
+
<div className="flex items-center gap-2">
|
| 149 |
+
<div
|
| 150 |
+
className={classNames(
|
| 151 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
| 152 |
+
'bg-bolt-elements-background-depth-3',
|
| 153 |
+
'text-purple-500',
|
| 154 |
+
)}
|
| 155 |
+
>
|
| 156 |
+
<TbLayoutGrid className="w-5 h-5" />
|
| 157 |
+
</div>
|
| 158 |
+
<div>
|
| 159 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
|
| 160 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
{/* Search */}
|
| 165 |
+
<div className="relative w-64">
|
| 166 |
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
| 167 |
+
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
|
| 168 |
+
</div>
|
| 169 |
+
<input
|
| 170 |
+
type="text"
|
| 171 |
+
value={searchQuery}
|
| 172 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 173 |
+
placeholder="Search tabs..."
|
| 174 |
+
className={classNames(
|
| 175 |
+
'w-full pl-10 pr-4 py-2 rounded-lg',
|
| 176 |
+
'bg-bolt-elements-background-depth-2',
|
| 177 |
+
'border border-bolt-elements-borderColor',
|
| 178 |
+
'text-bolt-elements-textPrimary',
|
| 179 |
+
'placeholder-bolt-elements-textTertiary',
|
| 180 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 181 |
+
'transition-all duration-200',
|
| 182 |
+
)}
|
| 183 |
+
/>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
{/* Tab Grid */}
|
| 188 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 189 |
+
{/* Default Section Header */}
|
| 190 |
+
{filteredTabs.some((tab) => DEFAULT_USER_TABS.includes(tab.id)) && (
|
| 191 |
+
<div className="col-span-full flex items-center gap-2 mt-4 mb-2">
|
| 192 |
+
<div className="i-ph:star-fill w-4 h-4 text-purple-500" />
|
| 193 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Default Tabs</span>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{/* Default Tabs */}
|
| 198 |
+
{filteredTabs
|
| 199 |
+
.filter((tab) => DEFAULT_USER_TABS.includes(tab.id))
|
| 200 |
+
.map((tab, index) => (
|
| 201 |
+
<motion.div
|
| 202 |
+
key={tab.id}
|
| 203 |
+
className={classNames(
|
| 204 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
| 205 |
+
'bg-bolt-elements-background-depth-2',
|
| 206 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 207 |
+
'transition-all duration-200',
|
| 208 |
+
'relative overflow-hidden group',
|
| 209 |
+
)}
|
| 210 |
+
initial={{ opacity: 0, y: 20 }}
|
| 211 |
+
animate={{ opacity: 1, y: 0 }}
|
| 212 |
+
transition={{ delay: index * 0.1 }}
|
| 213 |
+
whileHover={{ scale: 1.02 }}
|
| 214 |
+
>
|
| 215 |
+
{/* Status Badges */}
|
| 216 |
+
<div className="absolute top-1 right-1.5 flex gap-1">
|
| 217 |
+
<span className="px-1.5 py-0.25 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium mr-2">
|
| 218 |
+
Default
|
| 219 |
+
</span>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<div className="flex items-start gap-4 p-4">
|
| 223 |
+
<motion.div
|
| 224 |
+
className={classNames(
|
| 225 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
| 226 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
| 227 |
+
'transition-all duration-200',
|
| 228 |
+
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
| 229 |
+
)}
|
| 230 |
+
whileHover={{ scale: 1.1 }}
|
| 231 |
+
whileTap={{ scale: 0.9 }}
|
| 232 |
+
>
|
| 233 |
+
<div
|
| 234 |
+
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
| 235 |
+
>
|
| 236 |
+
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
| 237 |
+
</div>
|
| 238 |
+
</motion.div>
|
| 239 |
+
|
| 240 |
+
<div className="flex-1 min-w-0">
|
| 241 |
+
<div className="flex items-center justify-between gap-4">
|
| 242 |
+
<div>
|
| 243 |
+
<div className="flex items-center gap-2">
|
| 244 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
| 245 |
+
{TAB_LABELS[tab.id]}
|
| 246 |
+
</h4>
|
| 247 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
| 248 |
+
</div>
|
| 249 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
| 250 |
+
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
| 251 |
+
</p>
|
| 252 |
+
</div>
|
| 253 |
+
<Switch
|
| 254 |
+
checked={tab.visible}
|
| 255 |
+
onCheckedChange={(checked) => {
|
| 256 |
+
const isDisabled =
|
| 257 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
|
| 258 |
+
|
| 259 |
+
if (!isDisabled) {
|
| 260 |
+
handleTabVisibilityChange(tab.id, checked);
|
| 261 |
+
}
|
| 262 |
+
}}
|
| 263 |
+
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
|
| 264 |
+
'opacity-50 pointer-events-none':
|
| 265 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
| 266 |
+
})}
|
| 267 |
+
/>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<motion.div
|
| 273 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
| 274 |
+
animate={{
|
| 275 |
+
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
| 276 |
+
scale: tab.visible ? 1 : 0.98,
|
| 277 |
+
}}
|
| 278 |
+
transition={{ duration: 0.2 }}
|
| 279 |
+
/>
|
| 280 |
+
</motion.div>
|
| 281 |
+
))}
|
| 282 |
+
|
| 283 |
+
{/* Optional Section Header */}
|
| 284 |
+
{filteredTabs.some((tab) => OPTIONAL_USER_TABS.includes(tab.id)) && (
|
| 285 |
+
<div className="col-span-full flex items-center gap-2 mt-8 mb-2">
|
| 286 |
+
<div className="i-ph:plus-circle-fill w-4 h-4 text-blue-500" />
|
| 287 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Optional Tabs</span>
|
| 288 |
+
</div>
|
| 289 |
+
)}
|
| 290 |
+
|
| 291 |
+
{/* Optional Tabs */}
|
| 292 |
+
{filteredTabs
|
| 293 |
+
.filter((tab) => OPTIONAL_USER_TABS.includes(tab.id))
|
| 294 |
+
.map((tab, index) => (
|
| 295 |
+
<motion.div
|
| 296 |
+
key={tab.id}
|
| 297 |
+
className={classNames(
|
| 298 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
| 299 |
+
'bg-bolt-elements-background-depth-2',
|
| 300 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 301 |
+
'transition-all duration-200',
|
| 302 |
+
'relative overflow-hidden group',
|
| 303 |
+
)}
|
| 304 |
+
initial={{ opacity: 0, y: 20 }}
|
| 305 |
+
animate={{ opacity: 1, y: 0 }}
|
| 306 |
+
transition={{ delay: index * 0.1 }}
|
| 307 |
+
whileHover={{ scale: 1.02 }}
|
| 308 |
+
>
|
| 309 |
+
{/* Status Badges */}
|
| 310 |
+
<div className="absolute top-1 right-1.5 flex gap-1">
|
| 311 |
+
<span className="px-1.5 py-0.25 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium mr-2">
|
| 312 |
+
Optional
|
| 313 |
+
</span>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
<div className="flex items-start gap-4 p-4">
|
| 317 |
+
<motion.div
|
| 318 |
+
className={classNames(
|
| 319 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
| 320 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
| 321 |
+
'transition-all duration-200',
|
| 322 |
+
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
| 323 |
+
)}
|
| 324 |
+
whileHover={{ scale: 1.1 }}
|
| 325 |
+
whileTap={{ scale: 0.9 }}
|
| 326 |
+
>
|
| 327 |
+
<div
|
| 328 |
+
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
| 329 |
+
>
|
| 330 |
+
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
| 331 |
+
</div>
|
| 332 |
+
</motion.div>
|
| 333 |
+
|
| 334 |
+
<div className="flex-1 min-w-0">
|
| 335 |
+
<div className="flex items-center justify-between gap-4">
|
| 336 |
+
<div>
|
| 337 |
+
<div className="flex items-center gap-2">
|
| 338 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
| 339 |
+
{TAB_LABELS[tab.id]}
|
| 340 |
+
</h4>
|
| 341 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
| 342 |
+
</div>
|
| 343 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
| 344 |
+
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
| 345 |
+
</p>
|
| 346 |
+
</div>
|
| 347 |
+
<Switch
|
| 348 |
+
checked={tab.visible}
|
| 349 |
+
onCheckedChange={(checked) => {
|
| 350 |
+
const isDisabled =
|
| 351 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
|
| 352 |
+
|
| 353 |
+
if (!isDisabled) {
|
| 354 |
+
handleTabVisibilityChange(tab.id, checked);
|
| 355 |
+
}
|
| 356 |
+
}}
|
| 357 |
+
className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
|
| 358 |
+
'opacity-50 pointer-events-none':
|
| 359 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
| 360 |
+
})}
|
| 361 |
+
/>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<motion.div
|
| 367 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
| 368 |
+
animate={{
|
| 369 |
+
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
| 370 |
+
scale: tab.visible ? 1 : 0.98,
|
| 371 |
+
}}
|
| 372 |
+
transition={{ duration: 0.2 }}
|
| 373 |
+
/>
|
| 374 |
+
</motion.div>
|
| 375 |
+
))}
|
| 376 |
+
</div>
|
| 377 |
+
</motion.div>
|
| 378 |
+
</div>
|
| 379 |
+
);
|
| 380 |
+
};
|
app/components/@settings/shared/components/TabTile.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from 'framer-motion';
|
| 2 |
+
import * as Tooltip from '@radix-ui/react-tooltip';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
| 5 |
+
import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
|
| 6 |
+
|
| 7 |
+
interface TabTileProps {
|
| 8 |
+
tab: TabVisibilityConfig;
|
| 9 |
+
onClick?: () => void;
|
| 10 |
+
isActive?: boolean;
|
| 11 |
+
hasUpdate?: boolean;
|
| 12 |
+
statusMessage?: string;
|
| 13 |
+
description?: string;
|
| 14 |
+
isLoading?: boolean;
|
| 15 |
+
className?: string;
|
| 16 |
+
children?: React.ReactNode;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const TabTile: React.FC<TabTileProps> = ({
|
| 20 |
+
tab,
|
| 21 |
+
onClick,
|
| 22 |
+
isActive,
|
| 23 |
+
hasUpdate,
|
| 24 |
+
statusMessage,
|
| 25 |
+
description,
|
| 26 |
+
isLoading,
|
| 27 |
+
className,
|
| 28 |
+
children,
|
| 29 |
+
}: TabTileProps) => {
|
| 30 |
+
return (
|
| 31 |
+
<Tooltip.Provider delayDuration={200}>
|
| 32 |
+
<Tooltip.Root>
|
| 33 |
+
<Tooltip.Trigger asChild>
|
| 34 |
+
<motion.div
|
| 35 |
+
onClick={onClick}
|
| 36 |
+
className={classNames(
|
| 37 |
+
'relative flex flex-col items-center p-6 rounded-xl',
|
| 38 |
+
'w-full h-full min-h-[160px]',
|
| 39 |
+
'bg-white dark:bg-[#141414]',
|
| 40 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 41 |
+
'group',
|
| 42 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 43 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 44 |
+
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
| 45 |
+
isLoading ? 'cursor-wait opacity-70' : '',
|
| 46 |
+
className || '',
|
| 47 |
+
)}
|
| 48 |
+
>
|
| 49 |
+
{/* Main Content */}
|
| 50 |
+
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
| 51 |
+
{/* Icon */}
|
| 52 |
+
<motion.div
|
| 53 |
+
className={classNames(
|
| 54 |
+
'relative',
|
| 55 |
+
'w-14 h-14',
|
| 56 |
+
'flex items-center justify-center',
|
| 57 |
+
'rounded-xl',
|
| 58 |
+
'bg-gray-100 dark:bg-gray-800',
|
| 59 |
+
'ring-1 ring-gray-200 dark:ring-gray-700',
|
| 60 |
+
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
| 61 |
+
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
| 62 |
+
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
| 63 |
+
)}
|
| 64 |
+
>
|
| 65 |
+
<motion.div
|
| 66 |
+
className={classNames(
|
| 67 |
+
TAB_ICONS[tab.id],
|
| 68 |
+
'w-8 h-8',
|
| 69 |
+
'text-gray-600 dark:text-gray-300',
|
| 70 |
+
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
| 71 |
+
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
| 72 |
+
)}
|
| 73 |
+
/>
|
| 74 |
+
</motion.div>
|
| 75 |
+
|
| 76 |
+
{/* Label and Description */}
|
| 77 |
+
<div className="flex flex-col items-center mt-5 w-full">
|
| 78 |
+
<h3
|
| 79 |
+
className={classNames(
|
| 80 |
+
'text-[15px] font-medium leading-snug mb-2',
|
| 81 |
+
'text-gray-700 dark:text-gray-200',
|
| 82 |
+
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
| 83 |
+
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
| 84 |
+
)}
|
| 85 |
+
>
|
| 86 |
+
{TAB_LABELS[tab.id]}
|
| 87 |
+
</h3>
|
| 88 |
+
{description && (
|
| 89 |
+
<p
|
| 90 |
+
className={classNames(
|
| 91 |
+
'text-[13px] leading-relaxed',
|
| 92 |
+
'text-gray-500 dark:text-gray-400',
|
| 93 |
+
'max-w-[85%]',
|
| 94 |
+
'text-center',
|
| 95 |
+
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
| 96 |
+
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
| 97 |
+
)}
|
| 98 |
+
>
|
| 99 |
+
{description}
|
| 100 |
+
</p>
|
| 101 |
+
)}
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* Update Indicator with Tooltip */}
|
| 106 |
+
{hasUpdate && (
|
| 107 |
+
<>
|
| 108 |
+
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
| 109 |
+
<Tooltip.Portal>
|
| 110 |
+
<Tooltip.Content
|
| 111 |
+
className={classNames(
|
| 112 |
+
'px-3 py-1.5 rounded-lg',
|
| 113 |
+
'bg-[#18181B] text-white',
|
| 114 |
+
'text-sm font-medium',
|
| 115 |
+
'select-none',
|
| 116 |
+
'z-[100]',
|
| 117 |
+
)}
|
| 118 |
+
side="top"
|
| 119 |
+
sideOffset={5}
|
| 120 |
+
>
|
| 121 |
+
{statusMessage}
|
| 122 |
+
<Tooltip.Arrow className="fill-[#18181B]" />
|
| 123 |
+
</Tooltip.Content>
|
| 124 |
+
</Tooltip.Portal>
|
| 125 |
+
</>
|
| 126 |
+
)}
|
| 127 |
+
|
| 128 |
+
{/* Children (e.g. Beta Label) */}
|
| 129 |
+
{children}
|
| 130 |
+
</motion.div>
|
| 131 |
+
</Tooltip.Trigger>
|
| 132 |
+
</Tooltip.Root>
|
| 133 |
+
</Tooltip.Provider>
|
| 134 |
+
);
|
| 135 |
+
};
|
app/components/@settings/tabs/connections/ConnectionsTab.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from 'framer-motion';
|
| 2 |
+
import { GithubConnection } from './GithubConnection';
|
| 3 |
+
import { NetlifyConnection } from './NetlifyConnection';
|
| 4 |
+
|
| 5 |
+
export default function ConnectionsTab() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="space-y-4">
|
| 8 |
+
{/* Header */}
|
| 9 |
+
<motion.div
|
| 10 |
+
className="flex items-center gap-2 mb-2"
|
| 11 |
+
initial={{ opacity: 0, y: 20 }}
|
| 12 |
+
animate={{ opacity: 1, y: 0 }}
|
| 13 |
+
transition={{ delay: 0.1 }}
|
| 14 |
+
>
|
| 15 |
+
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
|
| 16 |
+
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
|
| 17 |
+
</motion.div>
|
| 18 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-6">
|
| 19 |
+
Manage your external service connections and integrations
|
| 20 |
+
</p>
|
| 21 |
+
|
| 22 |
+
<div className="grid grid-cols-1 gap-4">
|
| 23 |
+
<GithubConnection />
|
| 24 |
+
<NetlifyConnection />
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
}
|
app/components/@settings/tabs/connections/GithubConnection.tsx
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { logStore } from '~/lib/stores/logs';
|
| 5 |
+
import { classNames } from '~/utils/classNames';
|
| 6 |
+
|
| 7 |
+
interface GitHubUserResponse {
|
| 8 |
+
login: string;
|
| 9 |
+
avatar_url: string;
|
| 10 |
+
html_url: string;
|
| 11 |
+
name: string;
|
| 12 |
+
bio: string;
|
| 13 |
+
public_repos: number;
|
| 14 |
+
followers: number;
|
| 15 |
+
following: number;
|
| 16 |
+
created_at: string;
|
| 17 |
+
public_gists: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface GitHubRepoInfo {
|
| 21 |
+
name: string;
|
| 22 |
+
full_name: string;
|
| 23 |
+
html_url: string;
|
| 24 |
+
description: string;
|
| 25 |
+
stargazers_count: number;
|
| 26 |
+
forks_count: number;
|
| 27 |
+
default_branch: string;
|
| 28 |
+
updated_at: string;
|
| 29 |
+
languages_url: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
interface GitHubOrganization {
|
| 33 |
+
login: string;
|
| 34 |
+
avatar_url: string;
|
| 35 |
+
html_url: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface GitHubEvent {
|
| 39 |
+
id: string;
|
| 40 |
+
type: string;
|
| 41 |
+
repo: {
|
| 42 |
+
name: string;
|
| 43 |
+
};
|
| 44 |
+
created_at: string;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
interface GitHubLanguageStats {
|
| 48 |
+
[language: string]: number;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface GitHubStats {
|
| 52 |
+
repos: GitHubRepoInfo[];
|
| 53 |
+
totalStars: number;
|
| 54 |
+
totalForks: number;
|
| 55 |
+
organizations: GitHubOrganization[];
|
| 56 |
+
recentActivity: GitHubEvent[];
|
| 57 |
+
languages: GitHubLanguageStats;
|
| 58 |
+
totalGists: number;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
interface GitHubConnection {
|
| 62 |
+
user: GitHubUserResponse | null;
|
| 63 |
+
token: string;
|
| 64 |
+
tokenType: 'classic' | 'fine-grained';
|
| 65 |
+
stats?: GitHubStats;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function GithubConnection() {
|
| 69 |
+
const [connection, setConnection] = useState<GitHubConnection>({
|
| 70 |
+
user: null,
|
| 71 |
+
token: '',
|
| 72 |
+
tokenType: 'classic',
|
| 73 |
+
});
|
| 74 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 75 |
+
const [isConnecting, setIsConnecting] = useState(false);
|
| 76 |
+
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
| 77 |
+
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
| 78 |
+
|
| 79 |
+
const fetchGitHubStats = async (token: string) => {
|
| 80 |
+
try {
|
| 81 |
+
setIsFetchingStats(true);
|
| 82 |
+
|
| 83 |
+
const reposResponse = await fetch(
|
| 84 |
+
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
| 85 |
+
{
|
| 86 |
+
headers: {
|
| 87 |
+
Authorization: `Bearer ${token}`,
|
| 88 |
+
},
|
| 89 |
+
},
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
if (!reposResponse.ok) {
|
| 93 |
+
throw new Error('Failed to fetch repositories');
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
| 97 |
+
|
| 98 |
+
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
| 99 |
+
headers: {
|
| 100 |
+
Authorization: `Bearer ${token}`,
|
| 101 |
+
},
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
if (!orgsResponse.ok) {
|
| 105 |
+
throw new Error('Failed to fetch organizations');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
| 109 |
+
|
| 110 |
+
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
| 111 |
+
headers: {
|
| 112 |
+
Authorization: `Bearer ${token}`,
|
| 113 |
+
},
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
if (!eventsResponse.ok) {
|
| 117 |
+
throw new Error('Failed to fetch events');
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
| 121 |
+
|
| 122 |
+
const languagePromises = repos.map((repo) =>
|
| 123 |
+
fetch(repo.languages_url, {
|
| 124 |
+
headers: {
|
| 125 |
+
Authorization: `Bearer ${token}`,
|
| 126 |
+
},
|
| 127 |
+
}).then((res) => res.json() as Promise<Record<string, number>>),
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
const repoLanguages = await Promise.all(languagePromises);
|
| 131 |
+
const languages: GitHubLanguageStats = {};
|
| 132 |
+
|
| 133 |
+
repoLanguages.forEach((repoLang) => {
|
| 134 |
+
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
| 135 |
+
languages[lang] = (languages[lang] || 0) + bytes;
|
| 136 |
+
});
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
| 140 |
+
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
| 141 |
+
const totalGists = connection.user?.public_gists || 0;
|
| 142 |
+
|
| 143 |
+
setConnection((prev) => ({
|
| 144 |
+
...prev,
|
| 145 |
+
stats: {
|
| 146 |
+
repos,
|
| 147 |
+
totalStars,
|
| 148 |
+
totalForks,
|
| 149 |
+
organizations,
|
| 150 |
+
recentActivity,
|
| 151 |
+
languages,
|
| 152 |
+
totalGists,
|
| 153 |
+
},
|
| 154 |
+
}));
|
| 155 |
+
} catch (error) {
|
| 156 |
+
logStore.logError('Failed to fetch GitHub stats', { error });
|
| 157 |
+
toast.error('Failed to fetch GitHub statistics');
|
| 158 |
+
} finally {
|
| 159 |
+
setIsFetchingStats(false);
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
const savedConnection = localStorage.getItem('github_connection');
|
| 165 |
+
|
| 166 |
+
if (savedConnection) {
|
| 167 |
+
const parsed = JSON.parse(savedConnection);
|
| 168 |
+
|
| 169 |
+
if (!parsed.tokenType) {
|
| 170 |
+
parsed.tokenType = 'classic';
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
setConnection(parsed);
|
| 174 |
+
|
| 175 |
+
if (parsed.user && parsed.token) {
|
| 176 |
+
fetchGitHubStats(parsed.token);
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
setIsLoading(false);
|
| 181 |
+
}, []);
|
| 182 |
+
|
| 183 |
+
if (isLoading || isConnecting || isFetchingStats) {
|
| 184 |
+
return <LoadingSpinner />;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const fetchGithubUser = async (token: string) => {
|
| 188 |
+
try {
|
| 189 |
+
setIsConnecting(true);
|
| 190 |
+
|
| 191 |
+
const response = await fetch('https://api.github.com/user', {
|
| 192 |
+
headers: {
|
| 193 |
+
Authorization: `Bearer ${token}`,
|
| 194 |
+
},
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
if (!response.ok) {
|
| 198 |
+
throw new Error('Invalid token or unauthorized');
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const data = (await response.json()) as GitHubUserResponse;
|
| 202 |
+
const newConnection: GitHubConnection = {
|
| 203 |
+
user: data,
|
| 204 |
+
token,
|
| 205 |
+
tokenType: connection.tokenType,
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
| 209 |
+
setConnection(newConnection);
|
| 210 |
+
|
| 211 |
+
await fetchGitHubStats(token);
|
| 212 |
+
|
| 213 |
+
toast.success('Successfully connected to GitHub');
|
| 214 |
+
} catch (error) {
|
| 215 |
+
logStore.logError('Failed to authenticate with GitHub', { error });
|
| 216 |
+
toast.error('Failed to connect to GitHub');
|
| 217 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
| 218 |
+
} finally {
|
| 219 |
+
setIsConnecting(false);
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
const handleConnect = async (event: React.FormEvent) => {
|
| 224 |
+
event.preventDefault();
|
| 225 |
+
await fetchGithubUser(connection.token);
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
const handleDisconnect = () => {
|
| 229 |
+
localStorage.removeItem('github_connection');
|
| 230 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
| 231 |
+
toast.success('Disconnected from GitHub');
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
return (
|
| 235 |
+
<motion.div
|
| 236 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 237 |
+
initial={{ opacity: 0, y: 20 }}
|
| 238 |
+
animate={{ opacity: 1, y: 0 }}
|
| 239 |
+
transition={{ delay: 0.2 }}
|
| 240 |
+
>
|
| 241 |
+
<div className="p-6 space-y-6">
|
| 242 |
+
<div className="flex items-center gap-2">
|
| 243 |
+
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
| 244 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 248 |
+
<div>
|
| 249 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
| 250 |
+
<select
|
| 251 |
+
value={connection.tokenType}
|
| 252 |
+
onChange={(e) =>
|
| 253 |
+
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
| 254 |
+
}
|
| 255 |
+
disabled={isConnecting || !!connection.user}
|
| 256 |
+
className={classNames(
|
| 257 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 258 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 259 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 260 |
+
'text-bolt-elements-textPrimary',
|
| 261 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
| 262 |
+
'disabled:opacity-50',
|
| 263 |
+
)}
|
| 264 |
+
>
|
| 265 |
+
<option value="classic">Personal Access Token (Classic)</option>
|
| 266 |
+
<option value="fine-grained">Fine-grained Token</option>
|
| 267 |
+
</select>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<div>
|
| 271 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
| 272 |
+
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
| 273 |
+
</label>
|
| 274 |
+
<input
|
| 275 |
+
type="password"
|
| 276 |
+
value={connection.token}
|
| 277 |
+
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
| 278 |
+
disabled={isConnecting || !!connection.user}
|
| 279 |
+
placeholder={`Enter your GitHub ${
|
| 280 |
+
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
| 281 |
+
}`}
|
| 282 |
+
className={classNames(
|
| 283 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 284 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 285 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 286 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 287 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
| 288 |
+
'disabled:opacity-50',
|
| 289 |
+
)}
|
| 290 |
+
/>
|
| 291 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
| 292 |
+
<a
|
| 293 |
+
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
| 294 |
+
target="_blank"
|
| 295 |
+
rel="noopener noreferrer"
|
| 296 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
| 297 |
+
>
|
| 298 |
+
Get your token
|
| 299 |
+
<div className="i-ph:arrow-square-out w-10 h-5" />
|
| 300 |
+
</a>
|
| 301 |
+
<span className="mx-2">•</span>
|
| 302 |
+
<span>
|
| 303 |
+
Required scopes:{' '}
|
| 304 |
+
{connection.tokenType === 'classic'
|
| 305 |
+
? 'repo, read:org, read:user'
|
| 306 |
+
: 'Repository access, Organization access'}
|
| 307 |
+
</span>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<div className="flex items-center gap-3">
|
| 313 |
+
{!connection.user ? (
|
| 314 |
+
<button
|
| 315 |
+
onClick={handleConnect}
|
| 316 |
+
disabled={isConnecting || !connection.token}
|
| 317 |
+
className={classNames(
|
| 318 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 319 |
+
'bg-purple-500 text-white',
|
| 320 |
+
'hover:bg-purple-600',
|
| 321 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 322 |
+
)}
|
| 323 |
+
>
|
| 324 |
+
{isConnecting ? (
|
| 325 |
+
<>
|
| 326 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
| 327 |
+
Connecting...
|
| 328 |
+
</>
|
| 329 |
+
) : (
|
| 330 |
+
<>
|
| 331 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
| 332 |
+
Connect
|
| 333 |
+
</>
|
| 334 |
+
)}
|
| 335 |
+
</button>
|
| 336 |
+
) : (
|
| 337 |
+
<button
|
| 338 |
+
onClick={handleDisconnect}
|
| 339 |
+
className={classNames(
|
| 340 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 341 |
+
'bg-red-500 text-white',
|
| 342 |
+
'hover:bg-red-600',
|
| 343 |
+
)}
|
| 344 |
+
>
|
| 345 |
+
<div className="i-ph:plug-x w-4 h-4" />
|
| 346 |
+
Disconnect
|
| 347 |
+
</button>
|
| 348 |
+
)}
|
| 349 |
+
|
| 350 |
+
{connection.user && (
|
| 351 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
| 352 |
+
<div className="i-ph:check-circle w-4 h-4" />
|
| 353 |
+
Connected to GitHub
|
| 354 |
+
</span>
|
| 355 |
+
)}
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
{connection.user && connection.stats && (
|
| 359 |
+
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
| 360 |
+
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
|
| 361 |
+
<div className="flex items-center gap-4">
|
| 362 |
+
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
| 363 |
+
<div className="flex-1">
|
| 364 |
+
<div className="flex items-center justify-between">
|
| 365 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 366 |
+
{connection.user.name || connection.user.login}
|
| 367 |
+
</h3>
|
| 368 |
+
<div
|
| 369 |
+
className={classNames(
|
| 370 |
+
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
|
| 371 |
+
isStatsExpanded ? 'rotate-180' : '',
|
| 372 |
+
)}
|
| 373 |
+
/>
|
| 374 |
+
</div>
|
| 375 |
+
{connection.user.bio && (
|
| 376 |
+
<p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
| 377 |
+
)}
|
| 378 |
+
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
| 379 |
+
<span className="flex items-center gap-1">
|
| 380 |
+
<div className="i-ph:users w-4 h-4" />
|
| 381 |
+
{connection.user.followers} followers
|
| 382 |
+
</span>
|
| 383 |
+
<span className="flex items-center gap-1">
|
| 384 |
+
<div className="i-ph:book-bookmark w-4 h-4" />
|
| 385 |
+
{connection.user.public_repos} public repos
|
| 386 |
+
</span>
|
| 387 |
+
<span className="flex items-center gap-1">
|
| 388 |
+
<div className="i-ph:star w-4 h-4" />
|
| 389 |
+
{connection.stats.totalStars} stars
|
| 390 |
+
</span>
|
| 391 |
+
<span className="flex items-center gap-1">
|
| 392 |
+
<div className="i-ph:git-fork w-4 h-4" />
|
| 393 |
+
{connection.stats.totalForks} forks
|
| 394 |
+
</span>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
</button>
|
| 399 |
+
|
| 400 |
+
{isStatsExpanded && (
|
| 401 |
+
<div className="pt-4">
|
| 402 |
+
{connection.stats.organizations.length > 0 && (
|
| 403 |
+
<div className="mb-6">
|
| 404 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
| 405 |
+
<div className="flex flex-wrap gap-3">
|
| 406 |
+
{connection.stats.organizations.map((org) => (
|
| 407 |
+
<a
|
| 408 |
+
key={org.login}
|
| 409 |
+
href={org.html_url}
|
| 410 |
+
target="_blank"
|
| 411 |
+
rel="noopener noreferrer"
|
| 412 |
+
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
| 413 |
+
>
|
| 414 |
+
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
| 415 |
+
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
| 416 |
+
</a>
|
| 417 |
+
))}
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
)}
|
| 421 |
+
|
| 422 |
+
{/* Languages Section */}
|
| 423 |
+
<div className="mb-6">
|
| 424 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
| 425 |
+
<div className="flex flex-wrap gap-2">
|
| 426 |
+
{Object.entries(connection.stats.languages)
|
| 427 |
+
.sort(([, a], [, b]) => b - a)
|
| 428 |
+
.slice(0, 5)
|
| 429 |
+
.map(([language]) => (
|
| 430 |
+
<span
|
| 431 |
+
key={language}
|
| 432 |
+
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
| 433 |
+
>
|
| 434 |
+
{language}
|
| 435 |
+
</span>
|
| 436 |
+
))}
|
| 437 |
+
</div>
|
| 438 |
+
</div>
|
| 439 |
+
|
| 440 |
+
{/* Recent Activity Section */}
|
| 441 |
+
<div className="mb-6">
|
| 442 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
| 443 |
+
<div className="space-y-3">
|
| 444 |
+
{connection.stats.recentActivity.map((event) => (
|
| 445 |
+
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
| 446 |
+
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
| 447 |
+
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
| 448 |
+
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
| 449 |
+
<span>on</span>
|
| 450 |
+
<a
|
| 451 |
+
href={`https://github.com/${event.repo.name}`}
|
| 452 |
+
target="_blank"
|
| 453 |
+
rel="noopener noreferrer"
|
| 454 |
+
className="text-purple-500 hover:underline"
|
| 455 |
+
>
|
| 456 |
+
{event.repo.name}
|
| 457 |
+
</a>
|
| 458 |
+
</div>
|
| 459 |
+
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
| 460 |
+
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
| 461 |
+
{new Date(event.created_at).toLocaleTimeString()}
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
))}
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
{/* Additional Stats */}
|
| 469 |
+
<div className="grid grid-cols-4 gap-4 mb-6">
|
| 470 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 471 |
+
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
| 472 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 473 |
+
{new Date(connection.user.created_at).toLocaleDateString()}
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 477 |
+
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
| 478 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 479 |
+
{connection.stats.totalGists}
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 483 |
+
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
| 484 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 485 |
+
{connection.stats.organizations.length}
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
| 489 |
+
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
| 490 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 491 |
+
{Object.keys(connection.stats.languages).length}
|
| 492 |
+
</div>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
{/* Repositories Section */}
|
| 497 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
| 498 |
+
<div className="space-y-3">
|
| 499 |
+
{connection.stats.repos.map((repo) => (
|
| 500 |
+
<a
|
| 501 |
+
key={repo.full_name}
|
| 502 |
+
href={repo.html_url}
|
| 503 |
+
target="_blank"
|
| 504 |
+
rel="noopener noreferrer"
|
| 505 |
+
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
| 506 |
+
>
|
| 507 |
+
<div className="flex items-center justify-between">
|
| 508 |
+
<div>
|
| 509 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
| 510 |
+
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
| 511 |
+
{repo.name}
|
| 512 |
+
</h5>
|
| 513 |
+
{repo.description && (
|
| 514 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
| 515 |
+
)}
|
| 516 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
| 517 |
+
<span className="flex items-center gap-1">
|
| 518 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
| 519 |
+
{repo.default_branch}
|
| 520 |
+
</span>
|
| 521 |
+
<span>•</span>
|
| 522 |
+
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
| 526 |
+
<span className="flex items-center gap-1">
|
| 527 |
+
<div className="i-ph:star w-3 h-3" />
|
| 528 |
+
{repo.stargazers_count}
|
| 529 |
+
</span>
|
| 530 |
+
<span className="flex items-center gap-1">
|
| 531 |
+
<div className="i-ph:git-fork w-3 h-3" />
|
| 532 |
+
{repo.forks_count}
|
| 533 |
+
</span>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
</a>
|
| 537 |
+
))}
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
)}
|
| 541 |
+
</div>
|
| 542 |
+
)}
|
| 543 |
+
</div>
|
| 544 |
+
</motion.div>
|
| 545 |
+
);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function LoadingSpinner() {
|
| 549 |
+
return (
|
| 550 |
+
<div className="flex items-center justify-center p-4">
|
| 551 |
+
<div className="flex items-center gap-2">
|
| 552 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 553 |
+
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
| 554 |
+
</div>
|
| 555 |
+
</div>
|
| 556 |
+
);
|
| 557 |
+
}
|
app/components/@settings/tabs/connections/NetlifyConnection.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { useStore } from '@nanostores/react';
|
| 5 |
+
import { logStore } from '~/lib/stores/logs';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import {
|
| 8 |
+
netlifyConnection,
|
| 9 |
+
isConnecting,
|
| 10 |
+
isFetchingStats,
|
| 11 |
+
updateNetlifyConnection,
|
| 12 |
+
fetchNetlifyStats,
|
| 13 |
+
} from '~/lib/stores/netlify';
|
| 14 |
+
import type { NetlifyUser } from '~/types/netlify';
|
| 15 |
+
|
| 16 |
+
export function NetlifyConnection() {
|
| 17 |
+
const connection = useStore(netlifyConnection);
|
| 18 |
+
const connecting = useStore(isConnecting);
|
| 19 |
+
const fetchingStats = useStore(isFetchingStats);
|
| 20 |
+
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
const fetchSites = async () => {
|
| 24 |
+
if (connection.user && connection.token) {
|
| 25 |
+
await fetchNetlifyStats(connection.token);
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
fetchSites();
|
| 29 |
+
}, [connection.user, connection.token]);
|
| 30 |
+
|
| 31 |
+
const handleConnect = async (event: React.FormEvent) => {
|
| 32 |
+
event.preventDefault();
|
| 33 |
+
isConnecting.set(true);
|
| 34 |
+
|
| 35 |
+
try {
|
| 36 |
+
const response = await fetch('https://api.netlify.com/api/v1/user', {
|
| 37 |
+
headers: {
|
| 38 |
+
Authorization: `Bearer ${connection.token}`,
|
| 39 |
+
'Content-Type': 'application/json',
|
| 40 |
+
},
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
if (!response.ok) {
|
| 44 |
+
throw new Error('Invalid token or unauthorized');
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const userData = (await response.json()) as NetlifyUser;
|
| 48 |
+
updateNetlifyConnection({
|
| 49 |
+
user: userData,
|
| 50 |
+
token: connection.token,
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
await fetchNetlifyStats(connection.token);
|
| 54 |
+
toast.success('Successfully connected to Netlify');
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.error('Auth error:', error);
|
| 57 |
+
logStore.logError('Failed to authenticate with Netlify', { error });
|
| 58 |
+
toast.error('Failed to connect to Netlify');
|
| 59 |
+
updateNetlifyConnection({ user: null, token: '' });
|
| 60 |
+
} finally {
|
| 61 |
+
isConnecting.set(false);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const handleDisconnect = () => {
|
| 66 |
+
updateNetlifyConnection({ user: null, token: '' });
|
| 67 |
+
toast.success('Disconnected from Netlify');
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<motion.div
|
| 72 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 73 |
+
initial={{ opacity: 0, y: 20 }}
|
| 74 |
+
animate={{ opacity: 1, y: 0 }}
|
| 75 |
+
transition={{ delay: 0.3 }}
|
| 76 |
+
>
|
| 77 |
+
<div className="p-6 space-y-6">
|
| 78 |
+
<div className="flex items-center justify-between">
|
| 79 |
+
<div className="flex items-center gap-2">
|
| 80 |
+
<img
|
| 81 |
+
className="w-5 h-5"
|
| 82 |
+
height="24"
|
| 83 |
+
width="24"
|
| 84 |
+
crossOrigin="anonymous"
|
| 85 |
+
src="https://cdn.simpleicons.org/netlify"
|
| 86 |
+
/>
|
| 87 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{!connection.user ? (
|
| 92 |
+
<div className="space-y-4">
|
| 93 |
+
<div>
|
| 94 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
| 95 |
+
<input
|
| 96 |
+
type="password"
|
| 97 |
+
value={connection.token}
|
| 98 |
+
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
|
| 99 |
+
disabled={connecting}
|
| 100 |
+
placeholder="Enter your Netlify personal access token"
|
| 101 |
+
className={classNames(
|
| 102 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 103 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
| 104 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
| 105 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 106 |
+
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
|
| 107 |
+
'disabled:opacity-50',
|
| 108 |
+
)}
|
| 109 |
+
/>
|
| 110 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
| 111 |
+
<a
|
| 112 |
+
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
| 113 |
+
target="_blank"
|
| 114 |
+
rel="noopener noreferrer"
|
| 115 |
+
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
|
| 116 |
+
>
|
| 117 |
+
Get your token
|
| 118 |
+
<div className="i-ph:arrow-square-out w-4 h-4" />
|
| 119 |
+
</a>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<button
|
| 124 |
+
onClick={handleConnect}
|
| 125 |
+
disabled={connecting || !connection.token}
|
| 126 |
+
className={classNames(
|
| 127 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 128 |
+
'bg-[#00AD9F] text-white',
|
| 129 |
+
'hover:bg-[#00968A]',
|
| 130 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 131 |
+
)}
|
| 132 |
+
>
|
| 133 |
+
{connecting ? (
|
| 134 |
+
<>
|
| 135 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
| 136 |
+
Connecting...
|
| 137 |
+
</>
|
| 138 |
+
) : (
|
| 139 |
+
<>
|
| 140 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
| 141 |
+
Connect
|
| 142 |
+
</>
|
| 143 |
+
)}
|
| 144 |
+
</button>
|
| 145 |
+
</div>
|
| 146 |
+
) : (
|
| 147 |
+
<div className="space-y-6">
|
| 148 |
+
<div className="flex items-center justify-between">
|
| 149 |
+
<div className="flex items-center gap-3">
|
| 150 |
+
<button
|
| 151 |
+
onClick={handleDisconnect}
|
| 152 |
+
className={classNames(
|
| 153 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
| 154 |
+
'bg-red-500 text-white',
|
| 155 |
+
'hover:bg-red-600',
|
| 156 |
+
)}
|
| 157 |
+
>
|
| 158 |
+
<div className="i-ph:plug w-4 h-4" />
|
| 159 |
+
Disconnect
|
| 160 |
+
</button>
|
| 161 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
| 162 |
+
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
|
| 163 |
+
Connected to Netlify
|
| 164 |
+
</span>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
| 169 |
+
<img
|
| 170 |
+
src={connection.user.avatar_url}
|
| 171 |
+
referrerPolicy="no-referrer"
|
| 172 |
+
crossOrigin="anonymous"
|
| 173 |
+
alt={connection.user.full_name}
|
| 174 |
+
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
|
| 175 |
+
/>
|
| 176 |
+
<div>
|
| 177 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
|
| 178 |
+
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{fetchingStats ? (
|
| 183 |
+
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
|
| 184 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 185 |
+
Fetching Netlify sites...
|
| 186 |
+
</div>
|
| 187 |
+
) : (
|
| 188 |
+
<div>
|
| 189 |
+
<button
|
| 190 |
+
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
|
| 191 |
+
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
|
| 192 |
+
>
|
| 193 |
+
<div className="i-ph:buildings w-4 h-4" />
|
| 194 |
+
Your Sites ({connection.stats?.totalSites || 0})
|
| 195 |
+
<div
|
| 196 |
+
className={classNames(
|
| 197 |
+
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
|
| 198 |
+
isSitesExpanded ? 'rotate-180' : '',
|
| 199 |
+
)}
|
| 200 |
+
/>
|
| 201 |
+
</button>
|
| 202 |
+
{isSitesExpanded && connection.stats?.sites?.length ? (
|
| 203 |
+
<div className="grid gap-3">
|
| 204 |
+
{connection.stats.sites.map((site) => (
|
| 205 |
+
<a
|
| 206 |
+
key={site.id}
|
| 207 |
+
href={site.admin_url}
|
| 208 |
+
target="_blank"
|
| 209 |
+
rel="noopener noreferrer"
|
| 210 |
+
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
|
| 211 |
+
>
|
| 212 |
+
<div className="flex items-center justify-between">
|
| 213 |
+
<div>
|
| 214 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
| 215 |
+
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
|
| 216 |
+
{site.name}
|
| 217 |
+
</h5>
|
| 218 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
| 219 |
+
<a
|
| 220 |
+
href={site.url}
|
| 221 |
+
target="_blank"
|
| 222 |
+
rel="noopener noreferrer"
|
| 223 |
+
className="hover:text-[#00AD9F]"
|
| 224 |
+
>
|
| 225 |
+
{site.url}
|
| 226 |
+
</a>
|
| 227 |
+
{site.published_deploy && (
|
| 228 |
+
<>
|
| 229 |
+
<span>•</span>
|
| 230 |
+
<span className="flex items-center gap-1">
|
| 231 |
+
<div className="i-ph:clock w-3 h-3" />
|
| 232 |
+
{new Date(site.published_deploy.published_at).toLocaleDateString()}
|
| 233 |
+
</span>
|
| 234 |
+
</>
|
| 235 |
+
)}
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
{site.build_settings?.provider && (
|
| 239 |
+
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
| 240 |
+
<span className="flex items-center gap-1">
|
| 241 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
| 242 |
+
{site.build_settings.provider}
|
| 243 |
+
</span>
|
| 244 |
+
</div>
|
| 245 |
+
)}
|
| 246 |
+
</div>
|
| 247 |
+
</a>
|
| 248 |
+
))}
|
| 249 |
+
</div>
|
| 250 |
+
) : isSitesExpanded ? (
|
| 251 |
+
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
| 252 |
+
<div className="i-ph:info w-4 h-4" />
|
| 253 |
+
No sites found in your Netlify account
|
| 254 |
+
</div>
|
| 255 |
+
) : null}
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
+
</div>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
</motion.div>
|
| 262 |
+
);
|
| 263 |
+
}
|
app/components/@settings/tabs/connections/components/ConnectionForm.tsx
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect } from 'react';
|
| 2 |
+
import { classNames } from '~/utils/classNames';
|
| 3 |
+
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
|
| 4 |
+
import Cookies from 'js-cookie';
|
| 5 |
+
import { getLocalStorage } from '~/lib/persistence';
|
| 6 |
+
|
| 7 |
+
const GITHUB_TOKEN_KEY = 'github_token';
|
| 8 |
+
|
| 9 |
+
interface ConnectionFormProps {
|
| 10 |
+
authState: GitHubAuthState;
|
| 11 |
+
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
|
| 12 |
+
onSave: (e: React.FormEvent) => void;
|
| 13 |
+
onDisconnect: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
| 17 |
+
// Check for saved token on mount
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
|
| 20 |
+
|
| 21 |
+
if (savedToken && !authState.tokenInfo?.token) {
|
| 22 |
+
setAuthState((prev: GitHubAuthState) => ({
|
| 23 |
+
...prev,
|
| 24 |
+
tokenInfo: {
|
| 25 |
+
token: savedToken,
|
| 26 |
+
scope: [],
|
| 27 |
+
avatar_url: '',
|
| 28 |
+
name: null,
|
| 29 |
+
created_at: new Date().toISOString(),
|
| 30 |
+
followers: 0,
|
| 31 |
+
},
|
| 32 |
+
}));
|
| 33 |
+
}
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
|
| 38 |
+
<div className="p-6">
|
| 39 |
+
<div className="flex items-center justify-between mb-6">
|
| 40 |
+
<div className="flex items-center gap-3">
|
| 41 |
+
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 42 |
+
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
|
| 43 |
+
</div>
|
| 44 |
+
<div>
|
| 45 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
|
| 46 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<form onSubmit={onSave} className="space-y-4">
|
| 52 |
+
<div>
|
| 53 |
+
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
| 54 |
+
GitHub Username
|
| 55 |
+
</label>
|
| 56 |
+
<input
|
| 57 |
+
id="username"
|
| 58 |
+
type="text"
|
| 59 |
+
value={authState.username}
|
| 60 |
+
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
|
| 61 |
+
className={classNames(
|
| 62 |
+
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
| 63 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
| 64 |
+
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 65 |
+
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
| 66 |
+
'transition-all duration-200',
|
| 67 |
+
)}
|
| 68 |
+
placeholder="e.g., octocat"
|
| 69 |
+
/>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div>
|
| 73 |
+
<div className="flex items-center justify-between mb-2">
|
| 74 |
+
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
|
| 75 |
+
Personal Access Token
|
| 76 |
+
</label>
|
| 77 |
+
<a
|
| 78 |
+
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
|
| 79 |
+
target="_blank"
|
| 80 |
+
rel="noopener noreferrer"
|
| 81 |
+
className={classNames(
|
| 82 |
+
'inline-flex items-center gap-1.5 text-xs',
|
| 83 |
+
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
|
| 84 |
+
'transition-colors duration-200',
|
| 85 |
+
)}
|
| 86 |
+
>
|
| 87 |
+
<span>Generate new token</span>
|
| 88 |
+
<div className="i-ph:plus-circle" />
|
| 89 |
+
</a>
|
| 90 |
+
</div>
|
| 91 |
+
<input
|
| 92 |
+
id="token"
|
| 93 |
+
type="password"
|
| 94 |
+
value={authState.tokenInfo?.token || ''}
|
| 95 |
+
onChange={(e) =>
|
| 96 |
+
setAuthState((prev: GitHubAuthState) => ({
|
| 97 |
+
...prev,
|
| 98 |
+
tokenInfo: {
|
| 99 |
+
token: e.target.value,
|
| 100 |
+
scope: [],
|
| 101 |
+
avatar_url: '',
|
| 102 |
+
name: null,
|
| 103 |
+
created_at: new Date().toISOString(),
|
| 104 |
+
followers: 0,
|
| 105 |
+
},
|
| 106 |
+
username: '',
|
| 107 |
+
isConnected: false,
|
| 108 |
+
isVerifying: false,
|
| 109 |
+
isLoadingRepos: false,
|
| 110 |
+
}))
|
| 111 |
+
}
|
| 112 |
+
className={classNames(
|
| 113 |
+
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
| 114 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
| 115 |
+
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 116 |
+
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
| 117 |
+
'transition-all duration-200',
|
| 118 |
+
)}
|
| 119 |
+
placeholder="ghp_xxxxxxxxxxxx"
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 124 |
+
<div className="flex items-center gap-4">
|
| 125 |
+
{!authState.isConnected ? (
|
| 126 |
+
<button
|
| 127 |
+
type="submit"
|
| 128 |
+
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
|
| 129 |
+
className={classNames(
|
| 130 |
+
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
| 131 |
+
'bg-purple-500 hover:bg-purple-600',
|
| 132 |
+
'text-white',
|
| 133 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 134 |
+
)}
|
| 135 |
+
>
|
| 136 |
+
{authState.isVerifying ? (
|
| 137 |
+
<>
|
| 138 |
+
<div className="i-ph:spinner animate-spin" />
|
| 139 |
+
<span>Verifying...</span>
|
| 140 |
+
</>
|
| 141 |
+
) : (
|
| 142 |
+
<>
|
| 143 |
+
<div className="i-ph:plug-fill" />
|
| 144 |
+
<span>Connect</span>
|
| 145 |
+
</>
|
| 146 |
+
)}
|
| 147 |
+
</button>
|
| 148 |
+
) : (
|
| 149 |
+
<>
|
| 150 |
+
<button
|
| 151 |
+
onClick={onDisconnect}
|
| 152 |
+
className={classNames(
|
| 153 |
+
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
| 154 |
+
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
|
| 155 |
+
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
|
| 156 |
+
'text-bolt-elements-textPrimary',
|
| 157 |
+
)}
|
| 158 |
+
>
|
| 159 |
+
<div className="i-ph:plug-fill" />
|
| 160 |
+
<span>Disconnect</span>
|
| 161 |
+
</button>
|
| 162 |
+
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
|
| 163 |
+
<div className="i-ph:check-circle-fill" />
|
| 164 |
+
<span>Connected</span>
|
| 165 |
+
</span>
|
| 166 |
+
</>
|
| 167 |
+
)}
|
| 168 |
+
</div>
|
| 169 |
+
{authState.rateLimits && (
|
| 170 |
+
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
|
| 171 |
+
<div className="i-ph:clock-countdown opacity-60" />
|
| 172 |
+
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
</div>
|
| 176 |
+
</form>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
);
|
| 180 |
+
}
|
app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
|
| 5 |
+
import { GitBranch } from '@phosphor-icons/react';
|
| 6 |
+
|
| 7 |
+
interface GitHubBranch {
|
| 8 |
+
name: string;
|
| 9 |
+
default?: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface CreateBranchDialogProps {
|
| 13 |
+
isOpen: boolean;
|
| 14 |
+
onClose: () => void;
|
| 15 |
+
onConfirm: (branchName: string, sourceBranch: string) => void;
|
| 16 |
+
repository: GitHubRepoInfo;
|
| 17 |
+
branches?: GitHubBranch[];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
|
| 21 |
+
const [branchName, setBranchName] = useState('');
|
| 22 |
+
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
|
| 23 |
+
|
| 24 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 25 |
+
e.preventDefault();
|
| 26 |
+
onConfirm(branchName, sourceBranch);
|
| 27 |
+
setBranchName('');
|
| 28 |
+
onClose();
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<Dialog.Root open={isOpen} onOpenChange={onClose}>
|
| 33 |
+
<Dialog.Portal>
|
| 34 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
|
| 35 |
+
<Dialog.Content
|
| 36 |
+
className={classNames(
|
| 37 |
+
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
| 38 |
+
'w-full max-w-md p-6 rounded-xl shadow-lg',
|
| 39 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 40 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 41 |
+
)}
|
| 42 |
+
>
|
| 43 |
+
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
|
| 44 |
+
Create New Branch
|
| 45 |
+
</Dialog.Title>
|
| 46 |
+
|
| 47 |
+
<form onSubmit={handleSubmit}>
|
| 48 |
+
<div className="space-y-4">
|
| 49 |
+
<div>
|
| 50 |
+
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
| 51 |
+
Branch Name
|
| 52 |
+
</label>
|
| 53 |
+
<input
|
| 54 |
+
id="branchName"
|
| 55 |
+
type="text"
|
| 56 |
+
value={branchName}
|
| 57 |
+
onChange={(e) => setBranchName(e.target.value)}
|
| 58 |
+
placeholder="feature/my-new-branch"
|
| 59 |
+
className={classNames(
|
| 60 |
+
'w-full px-3 py-2 rounded-lg',
|
| 61 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 62 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 63 |
+
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
| 64 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
| 65 |
+
)}
|
| 66 |
+
required
|
| 67 |
+
/>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div>
|
| 71 |
+
<label
|
| 72 |
+
htmlFor="sourceBranch"
|
| 73 |
+
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
|
| 74 |
+
>
|
| 75 |
+
Source Branch
|
| 76 |
+
</label>
|
| 77 |
+
<select
|
| 78 |
+
id="sourceBranch"
|
| 79 |
+
value={sourceBranch}
|
| 80 |
+
onChange={(e) => setSourceBranch(e.target.value)}
|
| 81 |
+
className={classNames(
|
| 82 |
+
'w-full px-3 py-2 rounded-lg',
|
| 83 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 84 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 85 |
+
'text-bolt-elements-textPrimary',
|
| 86 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
| 87 |
+
)}
|
| 88 |
+
>
|
| 89 |
+
{branches?.map((branch) => (
|
| 90 |
+
<option key={branch.name} value={branch.name}>
|
| 91 |
+
{branch.name} {branch.default ? '(default)' : ''}
|
| 92 |
+
</option>
|
| 93 |
+
))}
|
| 94 |
+
</select>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
|
| 98 |
+
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
|
| 99 |
+
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
|
| 100 |
+
<li className="flex items-center gap-2">
|
| 101 |
+
<GitBranch className="text-lg" />
|
| 102 |
+
Repository: {repository.name}
|
| 103 |
+
</li>
|
| 104 |
+
{branchName && (
|
| 105 |
+
<li className="flex items-center gap-2">
|
| 106 |
+
<div className="i-ph:check-circle text-green-500" />
|
| 107 |
+
New branch will be created as: {branchName}
|
| 108 |
+
</li>
|
| 109 |
+
)}
|
| 110 |
+
<li className="flex items-center gap-2">
|
| 111 |
+
<div className="i-ph:check-circle text-green-500" />
|
| 112 |
+
Based on: {sourceBranch}
|
| 113 |
+
</li>
|
| 114 |
+
</ul>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<div className="mt-6 flex justify-end gap-3">
|
| 119 |
+
<button
|
| 120 |
+
type="button"
|
| 121 |
+
onClick={onClose}
|
| 122 |
+
className={classNames(
|
| 123 |
+
'px-4 py-2 rounded-lg text-sm font-medium',
|
| 124 |
+
'text-bolt-elements-textPrimary',
|
| 125 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 126 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
| 127 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
| 128 |
+
'transition-colors',
|
| 129 |
+
)}
|
| 130 |
+
>
|
| 131 |
+
Cancel
|
| 132 |
+
</button>
|
| 133 |
+
<button
|
| 134 |
+
type="submit"
|
| 135 |
+
className={classNames(
|
| 136 |
+
'px-4 py-2 rounded-lg text-sm font-medium',
|
| 137 |
+
'text-white bg-purple-500',
|
| 138 |
+
'hover:bg-purple-600',
|
| 139 |
+
'transition-colors',
|
| 140 |
+
)}
|
| 141 |
+
>
|
| 142 |
+
Create Branch
|
| 143 |
+
</button>
|
| 144 |
+
</div>
|
| 145 |
+
</form>
|
| 146 |
+
</Dialog.Content>
|
| 147 |
+
</Dialog.Portal>
|
| 148 |
+
</Dialog.Root>
|
| 149 |
+
);
|
| 150 |
+
}
|
app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
| 2 |
+
import { useState, useEffect } from 'react';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { motion } from 'framer-motion';
|
| 5 |
+
import { getLocalStorage } from '~/lib/persistence';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import type { GitHubUserResponse } from '~/types/GitHub';
|
| 8 |
+
import { logStore } from '~/lib/stores/logs';
|
| 9 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
| 10 |
+
import { extractRelativePath } from '~/utils/diff';
|
| 11 |
+
import { formatSize } from '~/utils/formatSize';
|
| 12 |
+
import type { FileMap, File } from '~/lib/stores/files';
|
| 13 |
+
import { Octokit } from '@octokit/rest';
|
| 14 |
+
|
| 15 |
+
interface PushToGitHubDialogProps {
|
| 16 |
+
isOpen: boolean;
|
| 17 |
+
onClose: () => void;
|
| 18 |
+
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface GitHubRepo {
|
| 22 |
+
name: string;
|
| 23 |
+
full_name: string;
|
| 24 |
+
html_url: string;
|
| 25 |
+
description: string;
|
| 26 |
+
stargazers_count: number;
|
| 27 |
+
forks_count: number;
|
| 28 |
+
default_branch: string;
|
| 29 |
+
updated_at: string;
|
| 30 |
+
language: string;
|
| 31 |
+
private: boolean;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
|
| 35 |
+
const [repoName, setRepoName] = useState('');
|
| 36 |
+
const [isPrivate, setIsPrivate] = useState(false);
|
| 37 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 38 |
+
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
| 39 |
+
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
| 40 |
+
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
| 41 |
+
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
| 42 |
+
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
| 43 |
+
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
|
| 44 |
+
|
| 45 |
+
// Load GitHub connection on mount
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
if (isOpen) {
|
| 48 |
+
const connection = getLocalStorage('github_connection');
|
| 49 |
+
|
| 50 |
+
if (connection?.user && connection?.token) {
|
| 51 |
+
setUser(connection.user);
|
| 52 |
+
|
| 53 |
+
// Only fetch if we have both user and token
|
| 54 |
+
if (connection.token.trim()) {
|
| 55 |
+
fetchRecentRepos(connection.token);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}, [isOpen]);
|
| 60 |
+
|
| 61 |
+
const fetchRecentRepos = async (token: string) => {
|
| 62 |
+
if (!token) {
|
| 63 |
+
logStore.logError('No GitHub token available');
|
| 64 |
+
toast.error('GitHub authentication required');
|
| 65 |
+
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
setIsFetchingRepos(true);
|
| 71 |
+
|
| 72 |
+
const response = await fetch(
|
| 73 |
+
'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
|
| 74 |
+
{
|
| 75 |
+
headers: {
|
| 76 |
+
Accept: 'application/vnd.github.v3+json',
|
| 77 |
+
Authorization: `Bearer ${token.trim()}`,
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
if (!response.ok) {
|
| 83 |
+
const errorData = await response.json().catch(() => ({}));
|
| 84 |
+
|
| 85 |
+
if (response.status === 401) {
|
| 86 |
+
toast.error('GitHub token expired. Please reconnect your account.');
|
| 87 |
+
|
| 88 |
+
// Clear invalid token
|
| 89 |
+
const connection = getLocalStorage('github_connection');
|
| 90 |
+
|
| 91 |
+
if (connection) {
|
| 92 |
+
localStorage.removeItem('github_connection');
|
| 93 |
+
setUser(null);
|
| 94 |
+
}
|
| 95 |
+
} else {
|
| 96 |
+
logStore.logError('Failed to fetch GitHub repositories', {
|
| 97 |
+
status: response.status,
|
| 98 |
+
statusText: response.statusText,
|
| 99 |
+
error: errorData,
|
| 100 |
+
});
|
| 101 |
+
toast.error(`Failed to fetch repositories: ${response.statusText}`);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const repos = (await response.json()) as GitHubRepo[];
|
| 108 |
+
setRecentRepos(repos);
|
| 109 |
+
} catch (error) {
|
| 110 |
+
logStore.logError('Failed to fetch GitHub repositories', { error });
|
| 111 |
+
toast.error('Failed to fetch recent repositories');
|
| 112 |
+
} finally {
|
| 113 |
+
setIsFetchingRepos(false);
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 118 |
+
e.preventDefault();
|
| 119 |
+
|
| 120 |
+
const connection = getLocalStorage('github_connection');
|
| 121 |
+
|
| 122 |
+
if (!connection?.token || !connection?.user) {
|
| 123 |
+
toast.error('Please connect your GitHub account in Settings > Connections first');
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (!repoName.trim()) {
|
| 128 |
+
toast.error('Repository name is required');
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
setIsLoading(true);
|
| 133 |
+
|
| 134 |
+
try {
|
| 135 |
+
// Check if repository exists first
|
| 136 |
+
const octokit = new Octokit({ auth: connection.token });
|
| 137 |
+
|
| 138 |
+
try {
|
| 139 |
+
await octokit.repos.get({
|
| 140 |
+
owner: connection.user.login,
|
| 141 |
+
repo: repoName,
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
// If we get here, the repo exists
|
| 145 |
+
const confirmOverwrite = window.confirm(
|
| 146 |
+
`Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
if (!confirmOverwrite) {
|
| 150 |
+
setIsLoading(false);
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
} catch (error) {
|
| 154 |
+
// 404 means repo doesn't exist, which is what we want for new repos
|
| 155 |
+
if (error instanceof Error && 'status' in error && error.status !== 404) {
|
| 156 |
+
throw error;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
|
| 161 |
+
setCreatedRepoUrl(repoUrl);
|
| 162 |
+
|
| 163 |
+
// Get list of pushed files
|
| 164 |
+
const files = workbenchStore.files.get();
|
| 165 |
+
const filesList = Object.entries(files as FileMap)
|
| 166 |
+
.filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
|
| 167 |
+
.map(([path, dirent]) => ({
|
| 168 |
+
path: extractRelativePath(path),
|
| 169 |
+
size: new TextEncoder().encode((dirent as File).content || '').length,
|
| 170 |
+
}));
|
| 171 |
+
|
| 172 |
+
setPushedFiles(filesList);
|
| 173 |
+
setShowSuccessDialog(true);
|
| 174 |
+
} catch (error) {
|
| 175 |
+
console.error('Error pushing to GitHub:', error);
|
| 176 |
+
toast.error('Failed to push to GitHub. Please check your repository name and try again.');
|
| 177 |
+
} finally {
|
| 178 |
+
setIsLoading(false);
|
| 179 |
+
}
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
const handleClose = () => {
|
| 183 |
+
setRepoName('');
|
| 184 |
+
setIsPrivate(false);
|
| 185 |
+
setShowSuccessDialog(false);
|
| 186 |
+
setCreatedRepoUrl('');
|
| 187 |
+
onClose();
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
// Success Dialog
|
| 191 |
+
if (showSuccessDialog) {
|
| 192 |
+
return (
|
| 193 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
| 194 |
+
<Dialog.Portal>
|
| 195 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
| 196 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
| 197 |
+
<motion.div
|
| 198 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 199 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 200 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 201 |
+
transition={{ duration: 0.2 }}
|
| 202 |
+
className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
|
| 203 |
+
>
|
| 204 |
+
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
| 205 |
+
<div className="p-6 space-y-4">
|
| 206 |
+
<div className="flex items-center justify-between">
|
| 207 |
+
<div className="flex items-center gap-2 text-green-500">
|
| 208 |
+
<div className="i-ph:check-circle w-5 h-5" />
|
| 209 |
+
<h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
|
| 210 |
+
</div>
|
| 211 |
+
<Dialog.Close
|
| 212 |
+
onClick={handleClose}
|
| 213 |
+
className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
| 214 |
+
>
|
| 215 |
+
<div className="i-ph:x w-5 h-5" />
|
| 216 |
+
</Dialog.Close>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
|
| 220 |
+
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
| 221 |
+
Repository URL
|
| 222 |
+
</p>
|
| 223 |
+
<div className="flex items-center gap-2">
|
| 224 |
+
<code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
|
| 225 |
+
{createdRepoUrl}
|
| 226 |
+
</code>
|
| 227 |
+
<motion.button
|
| 228 |
+
onClick={() => {
|
| 229 |
+
navigator.clipboard.writeText(createdRepoUrl);
|
| 230 |
+
toast.success('URL copied to clipboard');
|
| 231 |
+
}}
|
| 232 |
+
className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
|
| 233 |
+
whileHover={{ scale: 1.1 }}
|
| 234 |
+
whileTap={{ scale: 0.9 }}
|
| 235 |
+
>
|
| 236 |
+
<div className="i-ph:copy w-4 h-4" />
|
| 237 |
+
</motion.button>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
|
| 242 |
+
<p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
|
| 243 |
+
Pushed Files ({pushedFiles.length})
|
| 244 |
+
</p>
|
| 245 |
+
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
| 246 |
+
{pushedFiles.map((file) => (
|
| 247 |
+
<div
|
| 248 |
+
key={file.path}
|
| 249 |
+
className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
| 250 |
+
>
|
| 251 |
+
<span className="font-mono truncate flex-1">{file.path}</span>
|
| 252 |
+
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
|
| 253 |
+
{formatSize(file.size)}
|
| 254 |
+
</span>
|
| 255 |
+
</div>
|
| 256 |
+
))}
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
<div className="flex justify-end gap-2 pt-2">
|
| 261 |
+
<motion.a
|
| 262 |
+
href={createdRepoUrl}
|
| 263 |
+
target="_blank"
|
| 264 |
+
rel="noopener noreferrer"
|
| 265 |
+
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
|
| 266 |
+
whileHover={{ scale: 1.02 }}
|
| 267 |
+
whileTap={{ scale: 0.98 }}
|
| 268 |
+
>
|
| 269 |
+
<div className="i-ph:github-logo w-4 h-4" />
|
| 270 |
+
View Repository
|
| 271 |
+
</motion.a>
|
| 272 |
+
<motion.button
|
| 273 |
+
onClick={() => {
|
| 274 |
+
navigator.clipboard.writeText(createdRepoUrl);
|
| 275 |
+
toast.success('URL copied to clipboard');
|
| 276 |
+
}}
|
| 277 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
| 278 |
+
whileHover={{ scale: 1.02 }}
|
| 279 |
+
whileTap={{ scale: 0.98 }}
|
| 280 |
+
>
|
| 281 |
+
<div className="i-ph:copy w-4 h-4" />
|
| 282 |
+
Copy URL
|
| 283 |
+
</motion.button>
|
| 284 |
+
<motion.button
|
| 285 |
+
onClick={handleClose}
|
| 286 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
| 287 |
+
whileHover={{ scale: 1.02 }}
|
| 288 |
+
whileTap={{ scale: 0.98 }}
|
| 289 |
+
>
|
| 290 |
+
Close
|
| 291 |
+
</motion.button>
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
</Dialog.Content>
|
| 295 |
+
</motion.div>
|
| 296 |
+
</div>
|
| 297 |
+
</Dialog.Portal>
|
| 298 |
+
</Dialog.Root>
|
| 299 |
+
);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
if (!user) {
|
| 303 |
+
return (
|
| 304 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
| 305 |
+
<Dialog.Portal>
|
| 306 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
| 307 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
| 308 |
+
<motion.div
|
| 309 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 310 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 311 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 312 |
+
transition={{ duration: 0.2 }}
|
| 313 |
+
className="w-[90vw] md:w-[500px]"
|
| 314 |
+
>
|
| 315 |
+
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
| 316 |
+
<div className="text-center space-y-4">
|
| 317 |
+
<motion.div
|
| 318 |
+
initial={{ scale: 0.8 }}
|
| 319 |
+
animate={{ scale: 1 }}
|
| 320 |
+
transition={{ delay: 0.1 }}
|
| 321 |
+
className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
| 322 |
+
>
|
| 323 |
+
<div className="i-ph:github-logo w-6 h-6" />
|
| 324 |
+
</motion.div>
|
| 325 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
|
| 326 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
| 327 |
+
Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
|
| 328 |
+
</p>
|
| 329 |
+
<motion.button
|
| 330 |
+
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
| 331 |
+
whileHover={{ scale: 1.02 }}
|
| 332 |
+
whileTap={{ scale: 0.98 }}
|
| 333 |
+
onClick={handleClose}
|
| 334 |
+
>
|
| 335 |
+
<div className="i-ph:x-circle" />
|
| 336 |
+
Close
|
| 337 |
+
</motion.button>
|
| 338 |
+
</div>
|
| 339 |
+
</Dialog.Content>
|
| 340 |
+
</motion.div>
|
| 341 |
+
</div>
|
| 342 |
+
</Dialog.Portal>
|
| 343 |
+
</Dialog.Root>
|
| 344 |
+
);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
return (
|
| 348 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
| 349 |
+
<Dialog.Portal>
|
| 350 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
| 351 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
| 352 |
+
<motion.div
|
| 353 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 354 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 355 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 356 |
+
transition={{ duration: 0.2 }}
|
| 357 |
+
className="w-[90vw] md:w-[500px]"
|
| 358 |
+
>
|
| 359 |
+
<Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
|
| 360 |
+
<div className="p-6">
|
| 361 |
+
<div className="flex items-center gap-4 mb-6">
|
| 362 |
+
<motion.div
|
| 363 |
+
initial={{ scale: 0.8 }}
|
| 364 |
+
animate={{ scale: 1 }}
|
| 365 |
+
transition={{ delay: 0.1 }}
|
| 366 |
+
className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
| 367 |
+
>
|
| 368 |
+
<div className="i-ph:git-branch w-5 h-5" />
|
| 369 |
+
</motion.div>
|
| 370 |
+
<div>
|
| 371 |
+
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
|
| 372 |
+
Push to GitHub
|
| 373 |
+
</Dialog.Title>
|
| 374 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
| 375 |
+
Push your code to a new or existing GitHub repository
|
| 376 |
+
</p>
|
| 377 |
+
</div>
|
| 378 |
+
<Dialog.Close
|
| 379 |
+
className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
| 380 |
+
onClick={handleClose}
|
| 381 |
+
>
|
| 382 |
+
<div className="i-ph:x w-5 h-5" />
|
| 383 |
+
</Dialog.Close>
|
| 384 |
+
</div>
|
| 385 |
+
|
| 386 |
+
<div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
|
| 387 |
+
<img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
|
| 388 |
+
<div>
|
| 389 |
+
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
|
| 390 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 395 |
+
<div className="space-y-2">
|
| 396 |
+
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
|
| 397 |
+
Repository Name
|
| 398 |
+
</label>
|
| 399 |
+
<input
|
| 400 |
+
id="repoName"
|
| 401 |
+
type="text"
|
| 402 |
+
value={repoName}
|
| 403 |
+
onChange={(e) => setRepoName(e.target.value)}
|
| 404 |
+
placeholder="my-awesome-project"
|
| 405 |
+
className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
|
| 406 |
+
required
|
| 407 |
+
/>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
{recentRepos.length > 0 && (
|
| 411 |
+
<div className="space-y-2">
|
| 412 |
+
<label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
|
| 413 |
+
<div className="space-y-2">
|
| 414 |
+
{recentRepos.map((repo) => (
|
| 415 |
+
<motion.button
|
| 416 |
+
key={repo.full_name}
|
| 417 |
+
type="button"
|
| 418 |
+
onClick={() => setRepoName(repo.name)}
|
| 419 |
+
className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
|
| 420 |
+
whileHover={{ scale: 1.01 }}
|
| 421 |
+
whileTap={{ scale: 0.99 }}
|
| 422 |
+
>
|
| 423 |
+
<div className="flex items-center justify-between">
|
| 424 |
+
<div className="flex items-center gap-2">
|
| 425 |
+
<div className="i-ph:git-repository w-4 h-4 text-purple-500" />
|
| 426 |
+
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
| 427 |
+
{repo.name}
|
| 428 |
+
</span>
|
| 429 |
+
</div>
|
| 430 |
+
{repo.private && (
|
| 431 |
+
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
|
| 432 |
+
Private
|
| 433 |
+
</span>
|
| 434 |
+
)}
|
| 435 |
+
</div>
|
| 436 |
+
{repo.description && (
|
| 437 |
+
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
| 438 |
+
{repo.description}
|
| 439 |
+
</p>
|
| 440 |
+
)}
|
| 441 |
+
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
| 442 |
+
{repo.language && (
|
| 443 |
+
<span className="flex items-center gap-1">
|
| 444 |
+
<div className="i-ph:code w-3 h-3" />
|
| 445 |
+
{repo.language}
|
| 446 |
+
</span>
|
| 447 |
+
)}
|
| 448 |
+
<span className="flex items-center gap-1">
|
| 449 |
+
<div className="i-ph:star w-3 h-3" />
|
| 450 |
+
{repo.stargazers_count.toLocaleString()}
|
| 451 |
+
</span>
|
| 452 |
+
<span className="flex items-center gap-1">
|
| 453 |
+
<div className="i-ph:git-fork w-3 h-3" />
|
| 454 |
+
{repo.forks_count.toLocaleString()}
|
| 455 |
+
</span>
|
| 456 |
+
<span className="flex items-center gap-1">
|
| 457 |
+
<div className="i-ph:clock w-3 h-3" />
|
| 458 |
+
{new Date(repo.updated_at).toLocaleDateString()}
|
| 459 |
+
</span>
|
| 460 |
+
</div>
|
| 461 |
+
</motion.button>
|
| 462 |
+
))}
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
)}
|
| 466 |
+
|
| 467 |
+
{isFetchingRepos && (
|
| 468 |
+
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
|
| 469 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
|
| 470 |
+
Loading repositories...
|
| 471 |
+
</div>
|
| 472 |
+
)}
|
| 473 |
+
|
| 474 |
+
<div className="flex items-center gap-2">
|
| 475 |
+
<input
|
| 476 |
+
type="checkbox"
|
| 477 |
+
id="private"
|
| 478 |
+
checked={isPrivate}
|
| 479 |
+
onChange={(e) => setIsPrivate(e.target.checked)}
|
| 480 |
+
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
|
| 481 |
+
/>
|
| 482 |
+
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
|
| 483 |
+
Make repository private
|
| 484 |
+
</label>
|
| 485 |
+
</div>
|
| 486 |
+
|
| 487 |
+
<div className="pt-4 flex gap-2">
|
| 488 |
+
<motion.button
|
| 489 |
+
type="button"
|
| 490 |
+
onClick={handleClose}
|
| 491 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
| 492 |
+
whileHover={{ scale: 1.02 }}
|
| 493 |
+
whileTap={{ scale: 0.98 }}
|
| 494 |
+
>
|
| 495 |
+
Cancel
|
| 496 |
+
</motion.button>
|
| 497 |
+
<motion.button
|
| 498 |
+
type="submit"
|
| 499 |
+
disabled={isLoading}
|
| 500 |
+
className={classNames(
|
| 501 |
+
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
|
| 502 |
+
isLoading ? 'opacity-50 cursor-not-allowed' : '',
|
| 503 |
+
)}
|
| 504 |
+
whileHover={!isLoading ? { scale: 1.02 } : {}}
|
| 505 |
+
whileTap={!isLoading ? { scale: 0.98 } : {}}
|
| 506 |
+
>
|
| 507 |
+
{isLoading ? (
|
| 508 |
+
<>
|
| 509 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 510 |
+
Pushing...
|
| 511 |
+
</>
|
| 512 |
+
) : (
|
| 513 |
+
<>
|
| 514 |
+
<div className="i-ph:git-branch w-4 h-4" />
|
| 515 |
+
Push to GitHub
|
| 516 |
+
</>
|
| 517 |
+
)}
|
| 518 |
+
</motion.button>
|
| 519 |
+
</div>
|
| 520 |
+
</form>
|
| 521 |
+
</div>
|
| 522 |
+
</Dialog.Content>
|
| 523 |
+
</motion.div>
|
| 524 |
+
</div>
|
| 525 |
+
</Dialog.Portal>
|
| 526 |
+
</Dialog.Root>
|
| 527 |
+
);
|
| 528 |
+
}
|
app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
|
| 2 |
+
import { useState, useEffect } from 'react';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
| 5 |
+
import { classNames } from '~/utils/classNames';
|
| 6 |
+
import { getLocalStorage } from '~/lib/persistence';
|
| 7 |
+
import { motion } from 'framer-motion';
|
| 8 |
+
import { formatSize } from '~/utils/formatSize';
|
| 9 |
+
import { Input } from '~/components/ui/Input';
|
| 10 |
+
|
| 11 |
+
interface GitHubTreeResponse {
|
| 12 |
+
tree: Array<{
|
| 13 |
+
path: string;
|
| 14 |
+
type: string;
|
| 15 |
+
size?: number;
|
| 16 |
+
}>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface RepositorySelectionDialogProps {
|
| 20 |
+
isOpen: boolean;
|
| 21 |
+
onClose: () => void;
|
| 22 |
+
onSelect: (url: string) => void;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
interface SearchFilters {
|
| 26 |
+
language?: string;
|
| 27 |
+
stars?: number;
|
| 28 |
+
forks?: number;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
interface StatsDialogProps {
|
| 32 |
+
isOpen: boolean;
|
| 33 |
+
onClose: () => void;
|
| 34 |
+
onConfirm: () => void;
|
| 35 |
+
stats: RepositoryStats;
|
| 36 |
+
isLargeRepo?: boolean;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
|
| 40 |
+
return (
|
| 41 |
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
| 42 |
+
<Dialog.Portal>
|
| 43 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
|
| 44 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
| 45 |
+
<motion.div
|
| 46 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 47 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 48 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 49 |
+
transition={{ duration: 0.2 }}
|
| 50 |
+
className="w-[90vw] md:w-[500px]"
|
| 51 |
+
>
|
| 52 |
+
<Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
|
| 53 |
+
<div className="p-6 space-y-4">
|
| 54 |
+
<div>
|
| 55 |
+
<h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
|
| 56 |
+
<div className="mt-4 space-y-2">
|
| 57 |
+
<p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
|
| 58 |
+
<div className="space-y-2 text-sm text-[#111111] dark:text-white">
|
| 59 |
+
<div className="flex items-center gap-2">
|
| 60 |
+
<span className="i-ph:files text-purple-500 w-4 h-4" />
|
| 61 |
+
<span>Total Files: {stats.totalFiles}</span>
|
| 62 |
+
</div>
|
| 63 |
+
<div className="flex items-center gap-2">
|
| 64 |
+
<span className="i-ph:database text-purple-500 w-4 h-4" />
|
| 65 |
+
<span>Total Size: {formatSize(stats.totalSize)}</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div className="flex items-center gap-2">
|
| 68 |
+
<span className="i-ph:code text-purple-500 w-4 h-4" />
|
| 69 |
+
<span>
|
| 70 |
+
Languages:{' '}
|
| 71 |
+
{Object.entries(stats.languages)
|
| 72 |
+
.sort(([, a], [, b]) => b - a)
|
| 73 |
+
.slice(0, 3)
|
| 74 |
+
.map(([lang, size]) => `${lang} (${formatSize(size)})`)
|
| 75 |
+
.join(', ')}
|
| 76 |
+
</span>
|
| 77 |
+
</div>
|
| 78 |
+
{stats.hasPackageJson && (
|
| 79 |
+
<div className="flex items-center gap-2">
|
| 80 |
+
<span className="i-ph:package text-purple-500 w-4 h-4" />
|
| 81 |
+
<span>Has package.json</span>
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
{stats.hasDependencies && (
|
| 85 |
+
<div className="flex items-center gap-2">
|
| 86 |
+
<span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
|
| 87 |
+
<span>Has dependencies</span>
|
| 88 |
+
</div>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
{isLargeRepo && (
|
| 93 |
+
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
|
| 94 |
+
<span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
|
| 95 |
+
<div className="text-yellow-800 dark:text-yellow-500">
|
| 96 |
+
This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
|
| 97 |
+
and could impact performance.
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
)}
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
|
| 104 |
+
<button
|
| 105 |
+
onClick={onClose}
|
| 106 |
+
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
|
| 107 |
+
>
|
| 108 |
+
Cancel
|
| 109 |
+
</button>
|
| 110 |
+
<button
|
| 111 |
+
onClick={onConfirm}
|
| 112 |
+
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
|
| 113 |
+
>
|
| 114 |
+
OK
|
| 115 |
+
</button>
|
| 116 |
+
</div>
|
| 117 |
+
</Dialog.Content>
|
| 118 |
+
</motion.div>
|
| 119 |
+
</div>
|
| 120 |
+
</Dialog.Portal>
|
| 121 |
+
</Dialog.Root>
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
|
| 126 |
+
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
|
| 127 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 128 |
+
const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
|
| 129 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 130 |
+
const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
|
| 131 |
+
const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
|
| 132 |
+
const [customUrl, setCustomUrl] = useState('');
|
| 133 |
+
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
|
| 134 |
+
const [selectedBranch, setSelectedBranch] = useState('');
|
| 135 |
+
const [filters, setFilters] = useState<SearchFilters>({});
|
| 136 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 137 |
+
const [stats, setStats] = useState<RepositoryStats | null>(null);
|
| 138 |
+
const [showStatsDialog, setShowStatsDialog] = useState(false);
|
| 139 |
+
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
|
| 140 |
+
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
|
| 141 |
+
|
| 142 |
+
// Fetch user's repositories when dialog opens
|
| 143 |
+
useEffect(() => {
|
| 144 |
+
if (isOpen && activeTab === 'my-repos') {
|
| 145 |
+
fetchUserRepos();
|
| 146 |
+
}
|
| 147 |
+
}, [isOpen, activeTab]);
|
| 148 |
+
|
| 149 |
+
const fetchUserRepos = async () => {
|
| 150 |
+
const connection = getLocalStorage('github_connection');
|
| 151 |
+
|
| 152 |
+
if (!connection?.token) {
|
| 153 |
+
toast.error('Please connect your GitHub account first');
|
| 154 |
+
return;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
setIsLoading(true);
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
|
| 161 |
+
headers: {
|
| 162 |
+
Authorization: `Bearer ${connection.token}`,
|
| 163 |
+
},
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
if (!response.ok) {
|
| 167 |
+
throw new Error('Failed to fetch repositories');
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const data = await response.json();
|
| 171 |
+
|
| 172 |
+
// Add type assertion and validation
|
| 173 |
+
if (
|
| 174 |
+
Array.isArray(data) &&
|
| 175 |
+
data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
|
| 176 |
+
) {
|
| 177 |
+
setRepositories(data as GitHubRepoInfo[]);
|
| 178 |
+
} else {
|
| 179 |
+
throw new Error('Invalid repository data format');
|
| 180 |
+
}
|
| 181 |
+
} catch (error) {
|
| 182 |
+
console.error('Error fetching repos:', error);
|
| 183 |
+
toast.error('Failed to fetch your repositories');
|
| 184 |
+
} finally {
|
| 185 |
+
setIsLoading(false);
|
| 186 |
+
}
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const handleSearch = async (query: string) => {
|
| 190 |
+
setIsLoading(true);
|
| 191 |
+
setSearchResults([]);
|
| 192 |
+
|
| 193 |
+
try {
|
| 194 |
+
let searchQuery = query;
|
| 195 |
+
|
| 196 |
+
if (filters.language) {
|
| 197 |
+
searchQuery += ` language:${filters.language}`;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
if (filters.stars) {
|
| 201 |
+
searchQuery += ` stars:>${filters.stars}`;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if (filters.forks) {
|
| 205 |
+
searchQuery += ` forks:>${filters.forks}`;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const response = await fetch(
|
| 209 |
+
`https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
|
| 210 |
+
{
|
| 211 |
+
headers: {
|
| 212 |
+
Accept: 'application/vnd.github.v3+json',
|
| 213 |
+
},
|
| 214 |
+
},
|
| 215 |
+
);
|
| 216 |
+
|
| 217 |
+
if (!response.ok) {
|
| 218 |
+
throw new Error('Failed to search repositories');
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const data = await response.json();
|
| 222 |
+
|
| 223 |
+
// Add type assertion and validation
|
| 224 |
+
if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
|
| 225 |
+
setSearchResults(data.items as GitHubRepoInfo[]);
|
| 226 |
+
} else {
|
| 227 |
+
throw new Error('Invalid search results format');
|
| 228 |
+
}
|
| 229 |
+
} catch (error) {
|
| 230 |
+
console.error('Error searching repos:', error);
|
| 231 |
+
toast.error('Failed to search repositories');
|
| 232 |
+
} finally {
|
| 233 |
+
setIsLoading(false);
|
| 234 |
+
}
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
const fetchBranches = async (repo: GitHubRepoInfo) => {
|
| 238 |
+
setIsLoading(true);
|
| 239 |
+
|
| 240 |
+
try {
|
| 241 |
+
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
|
| 242 |
+
headers: {
|
| 243 |
+
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
|
| 244 |
+
},
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
if (!response.ok) {
|
| 248 |
+
throw new Error('Failed to fetch branches');
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const data = await response.json();
|
| 252 |
+
|
| 253 |
+
// Add type assertion and validation
|
| 254 |
+
if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
|
| 255 |
+
setBranches(
|
| 256 |
+
data.map((branch) => ({
|
| 257 |
+
name: branch.name,
|
| 258 |
+
default: branch.name === repo.default_branch,
|
| 259 |
+
})),
|
| 260 |
+
);
|
| 261 |
+
} else {
|
| 262 |
+
throw new Error('Invalid branch data format');
|
| 263 |
+
}
|
| 264 |
+
} catch (error) {
|
| 265 |
+
console.error('Error fetching branches:', error);
|
| 266 |
+
toast.error('Failed to fetch branches');
|
| 267 |
+
} finally {
|
| 268 |
+
setIsLoading(false);
|
| 269 |
+
}
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const handleRepoSelect = async (repo: GitHubRepoInfo) => {
|
| 273 |
+
setSelectedRepository(repo);
|
| 274 |
+
await fetchBranches(repo);
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
const formatGitUrl = (url: string): string => {
|
| 278 |
+
// Remove any tree references and ensure .git extension
|
| 279 |
+
const baseUrl = url
|
| 280 |
+
.replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
|
| 281 |
+
.replace(/\/$/, '') // Remove trailing slash
|
| 282 |
+
.replace(/\.git$/, ''); // Remove .git if present
|
| 283 |
+
return `${baseUrl}.git`;
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
|
| 287 |
+
try {
|
| 288 |
+
const [owner, repo] = repoUrl
|
| 289 |
+
.replace(/\.git$/, '')
|
| 290 |
+
.split('/')
|
| 291 |
+
.slice(-2);
|
| 292 |
+
|
| 293 |
+
const connection = getLocalStorage('github_connection');
|
| 294 |
+
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
|
| 295 |
+
|
| 296 |
+
// Fetch repository tree
|
| 297 |
+
const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
|
| 298 |
+
headers,
|
| 299 |
+
});
|
| 300 |
+
|
| 301 |
+
if (!treeResponse.ok) {
|
| 302 |
+
throw new Error('Failed to fetch repository structure');
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
|
| 306 |
+
|
| 307 |
+
// Calculate repository stats
|
| 308 |
+
let totalSize = 0;
|
| 309 |
+
let totalFiles = 0;
|
| 310 |
+
const languages: { [key: string]: number } = {};
|
| 311 |
+
let hasPackageJson = false;
|
| 312 |
+
let hasDependencies = false;
|
| 313 |
+
|
| 314 |
+
for (const file of treeData.tree) {
|
| 315 |
+
if (file.type === 'blob') {
|
| 316 |
+
totalFiles++;
|
| 317 |
+
|
| 318 |
+
if (file.size) {
|
| 319 |
+
totalSize += file.size;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// Check for package.json
|
| 323 |
+
if (file.path === 'package.json') {
|
| 324 |
+
hasPackageJson = true;
|
| 325 |
+
|
| 326 |
+
// Fetch package.json content to check dependencies
|
| 327 |
+
const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
|
| 328 |
+
headers,
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
if (contentResponse.ok) {
|
| 332 |
+
const content = (await contentResponse.json()) as GitHubContent;
|
| 333 |
+
const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
|
| 334 |
+
hasDependencies = !!(
|
| 335 |
+
packageJson.dependencies ||
|
| 336 |
+
packageJson.devDependencies ||
|
| 337 |
+
packageJson.peerDependencies
|
| 338 |
+
);
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// Detect language based on file extension
|
| 343 |
+
const ext = file.path.split('.').pop()?.toLowerCase();
|
| 344 |
+
|
| 345 |
+
if (ext) {
|
| 346 |
+
languages[ext] = (languages[ext] || 0) + (file.size || 0);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const stats: RepositoryStats = {
|
| 352 |
+
totalFiles,
|
| 353 |
+
totalSize,
|
| 354 |
+
languages,
|
| 355 |
+
hasPackageJson,
|
| 356 |
+
hasDependencies,
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
setStats(stats);
|
| 360 |
+
|
| 361 |
+
return stats;
|
| 362 |
+
} catch (error) {
|
| 363 |
+
console.error('Error verifying repository:', error);
|
| 364 |
+
toast.error('Failed to verify repository');
|
| 365 |
+
|
| 366 |
+
return null;
|
| 367 |
+
}
|
| 368 |
+
};
|
| 369 |
+
|
| 370 |
+
const handleImport = async () => {
|
| 371 |
+
try {
|
| 372 |
+
let gitUrl: string;
|
| 373 |
+
|
| 374 |
+
if (activeTab === 'url' && customUrl) {
|
| 375 |
+
gitUrl = formatGitUrl(customUrl);
|
| 376 |
+
} else if (selectedRepository) {
|
| 377 |
+
gitUrl = formatGitUrl(selectedRepository.html_url);
|
| 378 |
+
|
| 379 |
+
if (selectedBranch) {
|
| 380 |
+
gitUrl = `${gitUrl}#${selectedBranch}`;
|
| 381 |
+
}
|
| 382 |
+
} else {
|
| 383 |
+
return;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
// Verify repository before importing
|
| 387 |
+
const stats = await verifyRepository(gitUrl);
|
| 388 |
+
|
| 389 |
+
if (!stats) {
|
| 390 |
+
return;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
setCurrentStats(stats);
|
| 394 |
+
setPendingGitUrl(gitUrl);
|
| 395 |
+
setShowStatsDialog(true);
|
| 396 |
+
} catch (error) {
|
| 397 |
+
console.error('Error preparing repository:', error);
|
| 398 |
+
toast.error('Failed to prepare repository. Please try again.');
|
| 399 |
+
}
|
| 400 |
+
};
|
| 401 |
+
|
| 402 |
+
const handleStatsConfirm = () => {
|
| 403 |
+
setShowStatsDialog(false);
|
| 404 |
+
|
| 405 |
+
if (pendingGitUrl) {
|
| 406 |
+
onSelect(pendingGitUrl);
|
| 407 |
+
onClose();
|
| 408 |
+
}
|
| 409 |
+
};
|
| 410 |
+
|
| 411 |
+
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
|
| 412 |
+
let parsedValue: string | number | undefined = value;
|
| 413 |
+
|
| 414 |
+
if (key === 'stars' || key === 'forks') {
|
| 415 |
+
parsedValue = value ? parseInt(value, 10) : undefined;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
setFilters((prev) => ({ ...prev, [key]: parsedValue }));
|
| 419 |
+
handleSearch(searchQuery);
|
| 420 |
+
};
|
| 421 |
+
|
| 422 |
+
// Handle dialog close properly
|
| 423 |
+
const handleClose = () => {
|
| 424 |
+
setIsLoading(false); // Reset loading state
|
| 425 |
+
setSearchQuery(''); // Reset search
|
| 426 |
+
setSearchResults([]); // Reset results
|
| 427 |
+
onClose();
|
| 428 |
+
};
|
| 429 |
+
|
| 430 |
+
return (
|
| 431 |
+
<Dialog.Root
|
| 432 |
+
open={isOpen}
|
| 433 |
+
onOpenChange={(open) => {
|
| 434 |
+
if (!open) {
|
| 435 |
+
handleClose();
|
| 436 |
+
}
|
| 437 |
+
}}
|
| 438 |
+
>
|
| 439 |
+
<Dialog.Portal>
|
| 440 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
|
| 441 |
+
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
|
| 442 |
+
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
|
| 443 |
+
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
|
| 444 |
+
Import GitHub Repository
|
| 445 |
+
</Dialog.Title>
|
| 446 |
+
<Dialog.Close
|
| 447 |
+
onClick={handleClose}
|
| 448 |
+
className={classNames(
|
| 449 |
+
'p-2 rounded-lg transition-all duration-200 ease-in-out',
|
| 450 |
+
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
|
| 451 |
+
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
|
| 452 |
+
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
|
| 453 |
+
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
|
| 454 |
+
)}
|
| 455 |
+
>
|
| 456 |
+
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
|
| 457 |
+
<span className="sr-only">Close dialog</span>
|
| 458 |
+
</Dialog.Close>
|
| 459 |
+
</div>
|
| 460 |
+
|
| 461 |
+
<div className="p-4">
|
| 462 |
+
<div className="flex gap-2 mb-4">
|
| 463 |
+
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
|
| 464 |
+
<span className="i-ph:book-bookmark" />
|
| 465 |
+
My Repos
|
| 466 |
+
</TabButton>
|
| 467 |
+
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
|
| 468 |
+
<span className="i-ph:magnifying-glass" />
|
| 469 |
+
Search
|
| 470 |
+
</TabButton>
|
| 471 |
+
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
|
| 472 |
+
<span className="i-ph:link" />
|
| 473 |
+
URL
|
| 474 |
+
</TabButton>
|
| 475 |
+
</div>
|
| 476 |
+
|
| 477 |
+
{activeTab === 'url' ? (
|
| 478 |
+
<div className="space-y-4">
|
| 479 |
+
<Input
|
| 480 |
+
placeholder="Enter repository URL"
|
| 481 |
+
value={customUrl}
|
| 482 |
+
onChange={(e) => setCustomUrl(e.target.value)}
|
| 483 |
+
className={classNames('w-full', {
|
| 484 |
+
'border-red-500': false,
|
| 485 |
+
})}
|
| 486 |
+
/>
|
| 487 |
+
<button
|
| 488 |
+
onClick={handleImport}
|
| 489 |
+
disabled={!customUrl}
|
| 490 |
+
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
|
| 491 |
+
>
|
| 492 |
+
Import Repository
|
| 493 |
+
</button>
|
| 494 |
+
</div>
|
| 495 |
+
) : (
|
| 496 |
+
<>
|
| 497 |
+
{activeTab === 'search' && (
|
| 498 |
+
<div className="space-y-4 mb-4">
|
| 499 |
+
<div className="flex gap-2">
|
| 500 |
+
<input
|
| 501 |
+
type="text"
|
| 502 |
+
placeholder="Search repositories..."
|
| 503 |
+
value={searchQuery}
|
| 504 |
+
onChange={(e) => {
|
| 505 |
+
setSearchQuery(e.target.value);
|
| 506 |
+
handleSearch(e.target.value);
|
| 507 |
+
}}
|
| 508 |
+
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
|
| 509 |
+
/>
|
| 510 |
+
<button
|
| 511 |
+
onClick={() => setFilters({})}
|
| 512 |
+
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
| 513 |
+
>
|
| 514 |
+
<span className="i-ph:funnel-simple" />
|
| 515 |
+
</button>
|
| 516 |
+
</div>
|
| 517 |
+
<div className="grid grid-cols-2 gap-2">
|
| 518 |
+
<input
|
| 519 |
+
type="text"
|
| 520 |
+
placeholder="Filter by language..."
|
| 521 |
+
value={filters.language || ''}
|
| 522 |
+
onChange={(e) => {
|
| 523 |
+
setFilters({ ...filters, language: e.target.value });
|
| 524 |
+
handleSearch(searchQuery);
|
| 525 |
+
}}
|
| 526 |
+
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
| 527 |
+
/>
|
| 528 |
+
<input
|
| 529 |
+
type="number"
|
| 530 |
+
placeholder="Min stars..."
|
| 531 |
+
value={filters.stars || ''}
|
| 532 |
+
onChange={(e) => handleFilterChange('stars', e.target.value)}
|
| 533 |
+
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
| 534 |
+
/>
|
| 535 |
+
</div>
|
| 536 |
+
<input
|
| 537 |
+
type="number"
|
| 538 |
+
placeholder="Min forks..."
|
| 539 |
+
value={filters.forks || ''}
|
| 540 |
+
onChange={(e) => handleFilterChange('forks', e.target.value)}
|
| 541 |
+
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
|
| 542 |
+
/>
|
| 543 |
+
</div>
|
| 544 |
+
)}
|
| 545 |
+
|
| 546 |
+
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
| 547 |
+
{selectedRepository ? (
|
| 548 |
+
<div className="space-y-4">
|
| 549 |
+
<div className="flex items-center gap-2">
|
| 550 |
+
<button
|
| 551 |
+
onClick={() => setSelectedRepository(null)}
|
| 552 |
+
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
|
| 553 |
+
>
|
| 554 |
+
<span className="i-ph:arrow-left w-4 h-4" />
|
| 555 |
+
</button>
|
| 556 |
+
<h3 className="font-medium">{selectedRepository.full_name}</h3>
|
| 557 |
+
</div>
|
| 558 |
+
<div className="space-y-2">
|
| 559 |
+
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
|
| 560 |
+
<select
|
| 561 |
+
value={selectedBranch}
|
| 562 |
+
onChange={(e) => setSelectedBranch(e.target.value)}
|
| 563 |
+
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
|
| 564 |
+
>
|
| 565 |
+
{branches.map((branch) => (
|
| 566 |
+
<option
|
| 567 |
+
key={branch.name}
|
| 568 |
+
value={branch.name}
|
| 569 |
+
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
|
| 570 |
+
>
|
| 571 |
+
{branch.name} {branch.default ? '(default)' : ''}
|
| 572 |
+
</option>
|
| 573 |
+
))}
|
| 574 |
+
</select>
|
| 575 |
+
<button
|
| 576 |
+
onClick={handleImport}
|
| 577 |
+
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
|
| 578 |
+
>
|
| 579 |
+
Import Selected Branch
|
| 580 |
+
</button>
|
| 581 |
+
</div>
|
| 582 |
+
</div>
|
| 583 |
+
) : (
|
| 584 |
+
<RepositoryList
|
| 585 |
+
repos={activeTab === 'my-repos' ? repositories : searchResults}
|
| 586 |
+
isLoading={isLoading}
|
| 587 |
+
onSelect={handleRepoSelect}
|
| 588 |
+
activeTab={activeTab}
|
| 589 |
+
/>
|
| 590 |
+
)}
|
| 591 |
+
</div>
|
| 592 |
+
</>
|
| 593 |
+
)}
|
| 594 |
+
</div>
|
| 595 |
+
</Dialog.Content>
|
| 596 |
+
</Dialog.Portal>
|
| 597 |
+
{currentStats && (
|
| 598 |
+
<StatsDialog
|
| 599 |
+
isOpen={showStatsDialog}
|
| 600 |
+
onClose={handleStatsConfirm}
|
| 601 |
+
onConfirm={handleStatsConfirm}
|
| 602 |
+
stats={currentStats}
|
| 603 |
+
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
|
| 604 |
+
/>
|
| 605 |
+
)}
|
| 606 |
+
</Dialog.Root>
|
| 607 |
+
);
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 611 |
+
return (
|
| 612 |
+
<button
|
| 613 |
+
onClick={onClick}
|
| 614 |
+
className={classNames(
|
| 615 |
+
'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
|
| 616 |
+
active
|
| 617 |
+
? 'bg-purple-500 text-white hover:bg-purple-600'
|
| 618 |
+
: 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
|
| 619 |
+
)}
|
| 620 |
+
>
|
| 621 |
+
{children}
|
| 622 |
+
</button>
|
| 623 |
+
);
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
function RepositoryList({
|
| 627 |
+
repos,
|
| 628 |
+
isLoading,
|
| 629 |
+
onSelect,
|
| 630 |
+
activeTab,
|
| 631 |
+
}: {
|
| 632 |
+
repos: GitHubRepoInfo[];
|
| 633 |
+
isLoading: boolean;
|
| 634 |
+
onSelect: (repo: GitHubRepoInfo) => void;
|
| 635 |
+
activeTab: string;
|
| 636 |
+
}) {
|
| 637 |
+
if (isLoading) {
|
| 638 |
+
return (
|
| 639 |
+
<div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
|
| 640 |
+
<span className="i-ph:spinner animate-spin mr-2" />
|
| 641 |
+
Loading repositories...
|
| 642 |
+
</div>
|
| 643 |
+
);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
if (repos.length === 0) {
|
| 647 |
+
return (
|
| 648 |
+
<div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
|
| 649 |
+
<span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
|
| 650 |
+
<p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
|
| 651 |
+
</div>
|
| 652 |
+
);
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
|
| 659 |
+
return (
|
| 660 |
+
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
|
| 661 |
+
<div className="flex items-center justify-between mb-2">
|
| 662 |
+
<div className="flex items-center gap-2">
|
| 663 |
+
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
|
| 664 |
+
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
|
| 665 |
+
</div>
|
| 666 |
+
<button
|
| 667 |
+
onClick={onSelect}
|
| 668 |
+
className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
|
| 669 |
+
>
|
| 670 |
+
<span className="i-ph:download-simple w-4 h-4" />
|
| 671 |
+
Import
|
| 672 |
+
</button>
|
| 673 |
+
</div>
|
| 674 |
+
{repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
|
| 675 |
+
<div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
|
| 676 |
+
{repo.language && (
|
| 677 |
+
<span className="flex items-center gap-1">
|
| 678 |
+
<span className="i-ph:code" />
|
| 679 |
+
{repo.language}
|
| 680 |
+
</span>
|
| 681 |
+
)}
|
| 682 |
+
<span className="flex items-center gap-1">
|
| 683 |
+
<span className="i-ph:star" />
|
| 684 |
+
{repo.stargazers_count.toLocaleString()}
|
| 685 |
+
</span>
|
| 686 |
+
<span className="flex items-center gap-1">
|
| 687 |
+
<span className="i-ph:clock" />
|
| 688 |
+
{new Date(repo.updated_at).toLocaleDateString()}
|
| 689 |
+
</span>
|
| 690 |
+
</div>
|
| 691 |
+
</div>
|
| 692 |
+
);
|
| 693 |
+
}
|
app/components/@settings/tabs/connections/types/GitHub.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface GitHubUserResponse {
|
| 2 |
+
login: string;
|
| 3 |
+
avatar_url: string;
|
| 4 |
+
html_url: string;
|
| 5 |
+
name: string;
|
| 6 |
+
bio: string;
|
| 7 |
+
public_repos: number;
|
| 8 |
+
followers: number;
|
| 9 |
+
following: number;
|
| 10 |
+
public_gists: number;
|
| 11 |
+
created_at: string;
|
| 12 |
+
updated_at: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export interface GitHubRepoInfo {
|
| 16 |
+
name: string;
|
| 17 |
+
full_name: string;
|
| 18 |
+
html_url: string;
|
| 19 |
+
description: string;
|
| 20 |
+
stargazers_count: number;
|
| 21 |
+
forks_count: number;
|
| 22 |
+
default_branch: string;
|
| 23 |
+
updated_at: string;
|
| 24 |
+
language: string;
|
| 25 |
+
languages_url: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export interface GitHubOrganization {
|
| 29 |
+
login: string;
|
| 30 |
+
avatar_url: string;
|
| 31 |
+
description: string;
|
| 32 |
+
html_url: string;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export interface GitHubEvent {
|
| 36 |
+
id: string;
|
| 37 |
+
type: string;
|
| 38 |
+
created_at: string;
|
| 39 |
+
repo: {
|
| 40 |
+
name: string;
|
| 41 |
+
url: string;
|
| 42 |
+
};
|
| 43 |
+
payload: {
|
| 44 |
+
action?: string;
|
| 45 |
+
ref?: string;
|
| 46 |
+
ref_type?: string;
|
| 47 |
+
description?: string;
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export interface GitHubLanguageStats {
|
| 52 |
+
[key: string]: number;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface GitHubStats {
|
| 56 |
+
repos: GitHubRepoInfo[];
|
| 57 |
+
totalStars: number;
|
| 58 |
+
totalForks: number;
|
| 59 |
+
organizations: GitHubOrganization[];
|
| 60 |
+
recentActivity: GitHubEvent[];
|
| 61 |
+
languages: GitHubLanguageStats;
|
| 62 |
+
totalGists: number;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface GitHubConnection {
|
| 66 |
+
user: GitHubUserResponse | null;
|
| 67 |
+
token: string;
|
| 68 |
+
tokenType: 'classic' | 'fine-grained';
|
| 69 |
+
stats?: GitHubStats;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export interface GitHubTokenInfo {
|
| 73 |
+
token: string;
|
| 74 |
+
scope: string[];
|
| 75 |
+
avatar_url: string;
|
| 76 |
+
name: string | null;
|
| 77 |
+
created_at: string;
|
| 78 |
+
followers: number;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export interface GitHubRateLimits {
|
| 82 |
+
limit: number;
|
| 83 |
+
remaining: number;
|
| 84 |
+
reset: Date;
|
| 85 |
+
used: number;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export interface GitHubAuthState {
|
| 89 |
+
username: string;
|
| 90 |
+
tokenInfo: GitHubTokenInfo | null;
|
| 91 |
+
isConnected: boolean;
|
| 92 |
+
isVerifying: boolean;
|
| 93 |
+
isLoadingRepos: boolean;
|
| 94 |
+
rateLimits?: GitHubRateLimits;
|
| 95 |
+
}
|
app/components/@settings/tabs/data/DataTab.tsx
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
|
| 5 |
+
import { db, getAll, deleteById } from '~/lib/persistence';
|
| 6 |
+
|
| 7 |
+
export default function DataTab() {
|
| 8 |
+
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
|
| 9 |
+
const [isImportingKeys, setIsImportingKeys] = useState(false);
|
| 10 |
+
const [isResetting, setIsResetting] = useState(false);
|
| 11 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
| 12 |
+
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
|
| 13 |
+
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
|
| 14 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 15 |
+
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
|
| 16 |
+
|
| 17 |
+
const handleExportAllChats = async () => {
|
| 18 |
+
try {
|
| 19 |
+
if (!db) {
|
| 20 |
+
throw new Error('Database not initialized');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Get all chats from IndexedDB
|
| 24 |
+
const allChats = await getAll(db);
|
| 25 |
+
const exportData = {
|
| 26 |
+
chats: allChats,
|
| 27 |
+
exportDate: new Date().toISOString(),
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
// Download as JSON
|
| 31 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
| 32 |
+
const url = URL.createObjectURL(blob);
|
| 33 |
+
const a = document.createElement('a');
|
| 34 |
+
a.href = url;
|
| 35 |
+
a.download = `bolt-chats-${new Date().toISOString()}.json`;
|
| 36 |
+
document.body.appendChild(a);
|
| 37 |
+
a.click();
|
| 38 |
+
document.body.removeChild(a);
|
| 39 |
+
URL.revokeObjectURL(url);
|
| 40 |
+
|
| 41 |
+
toast.success('Chats exported successfully');
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error('Export error:', error);
|
| 44 |
+
toast.error('Failed to export chats');
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const handleExportSettings = () => {
|
| 49 |
+
try {
|
| 50 |
+
const settings = {
|
| 51 |
+
userProfile: localStorage.getItem('bolt_user_profile'),
|
| 52 |
+
settings: localStorage.getItem('bolt_settings'),
|
| 53 |
+
exportDate: new Date().toISOString(),
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
| 57 |
+
const url = URL.createObjectURL(blob);
|
| 58 |
+
const a = document.createElement('a');
|
| 59 |
+
a.href = url;
|
| 60 |
+
a.download = `bolt-settings-${new Date().toISOString()}.json`;
|
| 61 |
+
document.body.appendChild(a);
|
| 62 |
+
a.click();
|
| 63 |
+
document.body.removeChild(a);
|
| 64 |
+
URL.revokeObjectURL(url);
|
| 65 |
+
|
| 66 |
+
toast.success('Settings exported successfully');
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error('Export error:', error);
|
| 69 |
+
toast.error('Failed to export settings');
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 74 |
+
const file = event.target.files?.[0];
|
| 75 |
+
|
| 76 |
+
if (!file) {
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
try {
|
| 81 |
+
const content = await file.text();
|
| 82 |
+
const settings = JSON.parse(content);
|
| 83 |
+
|
| 84 |
+
if (settings.userProfile) {
|
| 85 |
+
localStorage.setItem('bolt_user_profile', settings.userProfile);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (settings.settings) {
|
| 89 |
+
localStorage.setItem('bolt_settings', settings.settings);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
window.location.reload(); // Reload to apply settings
|
| 93 |
+
toast.success('Settings imported successfully');
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error('Import error:', error);
|
| 96 |
+
toast.error('Failed to import settings');
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const handleImportAPIKeys = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 101 |
+
const file = event.target.files?.[0];
|
| 102 |
+
|
| 103 |
+
if (!file) {
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
setIsImportingKeys(true);
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
const content = await file.text();
|
| 111 |
+
const keys = JSON.parse(content);
|
| 112 |
+
|
| 113 |
+
// Validate and save each key
|
| 114 |
+
Object.entries(keys).forEach(([key, value]) => {
|
| 115 |
+
if (typeof value !== 'string') {
|
| 116 |
+
throw new Error(`Invalid value for key: ${key}`);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
localStorage.setItem(`bolt_${key.toLowerCase()}`, value);
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
toast.success('API keys imported successfully');
|
| 123 |
+
} catch (error) {
|
| 124 |
+
console.error('Error importing API keys:', error);
|
| 125 |
+
toast.error('Failed to import API keys');
|
| 126 |
+
} finally {
|
| 127 |
+
setIsImportingKeys(false);
|
| 128 |
+
|
| 129 |
+
if (apiKeyFileInputRef.current) {
|
| 130 |
+
apiKeyFileInputRef.current.value = '';
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const handleDownloadTemplate = () => {
|
| 136 |
+
setIsDownloadingTemplate(true);
|
| 137 |
+
|
| 138 |
+
try {
|
| 139 |
+
const template = {
|
| 140 |
+
Anthropic_API_KEY: '',
|
| 141 |
+
OpenAI_API_KEY: '',
|
| 142 |
+
Google_API_KEY: '',
|
| 143 |
+
Groq_API_KEY: '',
|
| 144 |
+
HuggingFace_API_KEY: '',
|
| 145 |
+
OpenRouter_API_KEY: '',
|
| 146 |
+
Deepseek_API_KEY: '',
|
| 147 |
+
Mistral_API_KEY: '',
|
| 148 |
+
OpenAILike_API_KEY: '',
|
| 149 |
+
Together_API_KEY: '',
|
| 150 |
+
xAI_API_KEY: '',
|
| 151 |
+
Perplexity_API_KEY: '',
|
| 152 |
+
Cohere_API_KEY: '',
|
| 153 |
+
AzureOpenAI_API_KEY: '',
|
| 154 |
+
OPENAI_LIKE_API_BASE_URL: '',
|
| 155 |
+
LMSTUDIO_API_BASE_URL: '',
|
| 156 |
+
OLLAMA_API_BASE_URL: '',
|
| 157 |
+
TOGETHER_API_BASE_URL: '',
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
|
| 161 |
+
const url = URL.createObjectURL(blob);
|
| 162 |
+
const a = document.createElement('a');
|
| 163 |
+
a.href = url;
|
| 164 |
+
a.download = 'bolt-api-keys-template.json';
|
| 165 |
+
document.body.appendChild(a);
|
| 166 |
+
a.click();
|
| 167 |
+
document.body.removeChild(a);
|
| 168 |
+
URL.revokeObjectURL(url);
|
| 169 |
+
|
| 170 |
+
toast.success('Template downloaded successfully');
|
| 171 |
+
} catch (error) {
|
| 172 |
+
console.error('Error downloading template:', error);
|
| 173 |
+
toast.error('Failed to download template');
|
| 174 |
+
} finally {
|
| 175 |
+
setIsDownloadingTemplate(false);
|
| 176 |
+
}
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
const handleResetSettings = async () => {
|
| 180 |
+
setIsResetting(true);
|
| 181 |
+
|
| 182 |
+
try {
|
| 183 |
+
// Clear all stored settings from localStorage
|
| 184 |
+
localStorage.removeItem('bolt_user_profile');
|
| 185 |
+
localStorage.removeItem('bolt_settings');
|
| 186 |
+
localStorage.removeItem('bolt_chat_history');
|
| 187 |
+
|
| 188 |
+
// Clear all data from IndexedDB
|
| 189 |
+
if (!db) {
|
| 190 |
+
throw new Error('Database not initialized');
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// Get all chats and delete them
|
| 194 |
+
const chats = await getAll(db as IDBDatabase);
|
| 195 |
+
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
|
| 196 |
+
await Promise.all(deletePromises);
|
| 197 |
+
|
| 198 |
+
// Close the dialog first
|
| 199 |
+
setShowResetInlineConfirm(false);
|
| 200 |
+
|
| 201 |
+
// Then reload and show success message
|
| 202 |
+
window.location.reload();
|
| 203 |
+
toast.success('Settings reset successfully');
|
| 204 |
+
} catch (error) {
|
| 205 |
+
console.error('Reset error:', error);
|
| 206 |
+
setShowResetInlineConfirm(false);
|
| 207 |
+
toast.error('Failed to reset settings');
|
| 208 |
+
} finally {
|
| 209 |
+
setIsResetting(false);
|
| 210 |
+
}
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
const handleDeleteAllChats = async () => {
|
| 214 |
+
setIsDeleting(true);
|
| 215 |
+
|
| 216 |
+
try {
|
| 217 |
+
// Clear chat history from localStorage
|
| 218 |
+
localStorage.removeItem('bolt_chat_history');
|
| 219 |
+
|
| 220 |
+
// Clear chats from IndexedDB
|
| 221 |
+
if (!db) {
|
| 222 |
+
throw new Error('Database not initialized');
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Get all chats and delete them one by one
|
| 226 |
+
const chats = await getAll(db as IDBDatabase);
|
| 227 |
+
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
|
| 228 |
+
await Promise.all(deletePromises);
|
| 229 |
+
|
| 230 |
+
// Close the dialog first
|
| 231 |
+
setShowDeleteInlineConfirm(false);
|
| 232 |
+
|
| 233 |
+
// Then show the success message
|
| 234 |
+
toast.success('Chat history deleted successfully');
|
| 235 |
+
} catch (error) {
|
| 236 |
+
console.error('Delete error:', error);
|
| 237 |
+
setShowDeleteInlineConfirm(false);
|
| 238 |
+
toast.error('Failed to delete chat history');
|
| 239 |
+
} finally {
|
| 240 |
+
setIsDeleting(false);
|
| 241 |
+
}
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
return (
|
| 245 |
+
<div className="space-y-6">
|
| 246 |
+
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
|
| 247 |
+
{/* Reset Settings Dialog */}
|
| 248 |
+
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
|
| 249 |
+
<Dialog showCloseButton={false} className="z-[1000]">
|
| 250 |
+
<div className="p-6">
|
| 251 |
+
<div className="flex items-center gap-3">
|
| 252 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
|
| 253 |
+
<DialogTitle>Reset All Settings?</DialogTitle>
|
| 254 |
+
</div>
|
| 255 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
| 256 |
+
This will reset all your settings to their default values. This action cannot be undone.
|
| 257 |
+
</p>
|
| 258 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
| 259 |
+
<DialogClose asChild>
|
| 260 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
| 261 |
+
Cancel
|
| 262 |
+
</button>
|
| 263 |
+
</DialogClose>
|
| 264 |
+
<motion.button
|
| 265 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-yellow-600 dark:text-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-500/10 border border-transparent hover:border-yellow-500/10 dark:hover:border-yellow-500/20"
|
| 266 |
+
onClick={handleResetSettings}
|
| 267 |
+
disabled={isResetting}
|
| 268 |
+
whileHover={{ scale: 1.02 }}
|
| 269 |
+
whileTap={{ scale: 0.98 }}
|
| 270 |
+
>
|
| 271 |
+
{isResetting ? (
|
| 272 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 273 |
+
) : (
|
| 274 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
| 275 |
+
)}
|
| 276 |
+
Reset Settings
|
| 277 |
+
</motion.button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
</Dialog>
|
| 281 |
+
</DialogRoot>
|
| 282 |
+
|
| 283 |
+
{/* Delete Confirmation Dialog */}
|
| 284 |
+
<DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
|
| 285 |
+
<Dialog showCloseButton={false} className="z-[1000]">
|
| 286 |
+
<div className="p-6">
|
| 287 |
+
<div className="flex items-center gap-3">
|
| 288 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />
|
| 289 |
+
<DialogTitle>Delete All Chats?</DialogTitle>
|
| 290 |
+
</div>
|
| 291 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
| 292 |
+
This will permanently delete all your chat history. This action cannot be undone.
|
| 293 |
+
</p>
|
| 294 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
| 295 |
+
<DialogClose asChild>
|
| 296 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
| 297 |
+
Cancel
|
| 298 |
+
</button>
|
| 299 |
+
</DialogClose>
|
| 300 |
+
<motion.button
|
| 301 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-red-500 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 border border-transparent hover:border-red-500/10 dark:hover:border-red-500/20"
|
| 302 |
+
onClick={handleDeleteAllChats}
|
| 303 |
+
disabled={isDeleting}
|
| 304 |
+
whileHover={{ scale: 1.02 }}
|
| 305 |
+
whileTap={{ scale: 0.98 }}
|
| 306 |
+
>
|
| 307 |
+
{isDeleting ? (
|
| 308 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 309 |
+
) : (
|
| 310 |
+
<div className="i-ph:trash w-4 h-4" />
|
| 311 |
+
)}
|
| 312 |
+
Delete All
|
| 313 |
+
</motion.button>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
</Dialog>
|
| 317 |
+
</DialogRoot>
|
| 318 |
+
|
| 319 |
+
{/* Chat History Section */}
|
| 320 |
+
<motion.div
|
| 321 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 322 |
+
initial={{ opacity: 0, y: 20 }}
|
| 323 |
+
animate={{ opacity: 1, y: 0 }}
|
| 324 |
+
transition={{ delay: 0.1 }}
|
| 325 |
+
>
|
| 326 |
+
<div className="flex items-center gap-2 mb-2">
|
| 327 |
+
<div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
|
| 328 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Chat History</h3>
|
| 329 |
+
</div>
|
| 330 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Export or delete all your chat history.</p>
|
| 331 |
+
<div className="flex gap-4">
|
| 332 |
+
<motion.button
|
| 333 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
| 334 |
+
whileHover={{ scale: 1.02 }}
|
| 335 |
+
whileTap={{ scale: 0.98 }}
|
| 336 |
+
onClick={handleExportAllChats}
|
| 337 |
+
>
|
| 338 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
| 339 |
+
Export All Chats
|
| 340 |
+
</motion.button>
|
| 341 |
+
<motion.button
|
| 342 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 text-red-500 text-sm hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
| 343 |
+
whileHover={{ scale: 1.02 }}
|
| 344 |
+
whileTap={{ scale: 0.98 }}
|
| 345 |
+
onClick={() => setShowDeleteInlineConfirm(true)}
|
| 346 |
+
>
|
| 347 |
+
<div className="i-ph:trash w-4 h-4" />
|
| 348 |
+
Delete All Chats
|
| 349 |
+
</motion.button>
|
| 350 |
+
</div>
|
| 351 |
+
</motion.div>
|
| 352 |
+
|
| 353 |
+
{/* Settings Backup Section */}
|
| 354 |
+
<motion.div
|
| 355 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 356 |
+
initial={{ opacity: 0, y: 20 }}
|
| 357 |
+
animate={{ opacity: 1, y: 0 }}
|
| 358 |
+
transition={{ delay: 0.2 }}
|
| 359 |
+
>
|
| 360 |
+
<div className="flex items-center gap-2 mb-2">
|
| 361 |
+
<div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
|
| 362 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Settings Backup</h3>
|
| 363 |
+
</div>
|
| 364 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
| 365 |
+
Export your settings to a JSON file or import settings from a previously exported file.
|
| 366 |
+
</p>
|
| 367 |
+
<div className="flex gap-4">
|
| 368 |
+
<motion.button
|
| 369 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
| 370 |
+
whileHover={{ scale: 1.02 }}
|
| 371 |
+
whileTap={{ scale: 0.98 }}
|
| 372 |
+
onClick={handleExportSettings}
|
| 373 |
+
>
|
| 374 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
| 375 |
+
Export Settings
|
| 376 |
+
</motion.button>
|
| 377 |
+
<motion.button
|
| 378 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
| 379 |
+
whileHover={{ scale: 1.02 }}
|
| 380 |
+
whileTap={{ scale: 0.98 }}
|
| 381 |
+
onClick={() => fileInputRef.current?.click()}
|
| 382 |
+
>
|
| 383 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
| 384 |
+
Import Settings
|
| 385 |
+
</motion.button>
|
| 386 |
+
<motion.button
|
| 387 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20 dark:text-yellow-500"
|
| 388 |
+
whileHover={{ scale: 1.02 }}
|
| 389 |
+
whileTap={{ scale: 0.98 }}
|
| 390 |
+
onClick={() => setShowResetInlineConfirm(true)}
|
| 391 |
+
>
|
| 392 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
| 393 |
+
Reset Settings
|
| 394 |
+
</motion.button>
|
| 395 |
+
</div>
|
| 396 |
+
</motion.div>
|
| 397 |
+
|
| 398 |
+
{/* API Keys Management Section */}
|
| 399 |
+
<motion.div
|
| 400 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 401 |
+
initial={{ opacity: 0, y: 20 }}
|
| 402 |
+
animate={{ opacity: 1, y: 0 }}
|
| 403 |
+
transition={{ delay: 0.3 }}
|
| 404 |
+
>
|
| 405 |
+
<div className="flex items-center gap-2 mb-2">
|
| 406 |
+
<div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
|
| 407 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys Management</h3>
|
| 408 |
+
</div>
|
| 409 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
| 410 |
+
Import API keys from a JSON file or download a template to fill in your keys.
|
| 411 |
+
</p>
|
| 412 |
+
<div className="flex gap-4">
|
| 413 |
+
<input
|
| 414 |
+
ref={apiKeyFileInputRef}
|
| 415 |
+
type="file"
|
| 416 |
+
accept=".json"
|
| 417 |
+
onChange={handleImportAPIKeys}
|
| 418 |
+
className="hidden"
|
| 419 |
+
/>
|
| 420 |
+
<motion.button
|
| 421 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
| 422 |
+
whileHover={{ scale: 1.02 }}
|
| 423 |
+
whileTap={{ scale: 0.98 }}
|
| 424 |
+
onClick={handleDownloadTemplate}
|
| 425 |
+
disabled={isDownloadingTemplate}
|
| 426 |
+
>
|
| 427 |
+
{isDownloadingTemplate ? (
|
| 428 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 429 |
+
) : (
|
| 430 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
| 431 |
+
)}
|
| 432 |
+
Download Template
|
| 433 |
+
</motion.button>
|
| 434 |
+
<motion.button
|
| 435 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
| 436 |
+
whileHover={{ scale: 1.02 }}
|
| 437 |
+
whileTap={{ scale: 0.98 }}
|
| 438 |
+
onClick={() => apiKeyFileInputRef.current?.click()}
|
| 439 |
+
disabled={isImportingKeys}
|
| 440 |
+
>
|
| 441 |
+
{isImportingKeys ? (
|
| 442 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 443 |
+
) : (
|
| 444 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
| 445 |
+
)}
|
| 446 |
+
Import API Keys
|
| 447 |
+
</motion.button>
|
| 448 |
+
</div>
|
| 449 |
+
</motion.div>
|
| 450 |
+
</div>
|
| 451 |
+
);
|
| 452 |
+
}
|
app/components/@settings/tabs/debug/DebugTab.tsx
ADDED
|
@@ -0,0 +1,2045 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
| 2 |
+
import { toast } from 'react-toastify';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
| 5 |
+
import { useStore } from '@nanostores/react';
|
| 6 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
|
| 7 |
+
import { Progress } from '~/components/ui/Progress';
|
| 8 |
+
import { ScrollArea } from '~/components/ui/ScrollArea';
|
| 9 |
+
import { Badge } from '~/components/ui/Badge';
|
| 10 |
+
import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
| 11 |
+
import { jsPDF } from 'jspdf';
|
| 12 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 13 |
+
|
| 14 |
+
interface SystemInfo {
|
| 15 |
+
os: string;
|
| 16 |
+
arch: string;
|
| 17 |
+
platform: string;
|
| 18 |
+
cpus: string;
|
| 19 |
+
memory: {
|
| 20 |
+
total: string;
|
| 21 |
+
free: string;
|
| 22 |
+
used: string;
|
| 23 |
+
percentage: number;
|
| 24 |
+
};
|
| 25 |
+
node: string;
|
| 26 |
+
browser: {
|
| 27 |
+
name: string;
|
| 28 |
+
version: string;
|
| 29 |
+
language: string;
|
| 30 |
+
userAgent: string;
|
| 31 |
+
cookiesEnabled: boolean;
|
| 32 |
+
online: boolean;
|
| 33 |
+
platform: string;
|
| 34 |
+
cores: number;
|
| 35 |
+
};
|
| 36 |
+
screen: {
|
| 37 |
+
width: number;
|
| 38 |
+
height: number;
|
| 39 |
+
colorDepth: number;
|
| 40 |
+
pixelRatio: number;
|
| 41 |
+
};
|
| 42 |
+
time: {
|
| 43 |
+
timezone: string;
|
| 44 |
+
offset: number;
|
| 45 |
+
locale: string;
|
| 46 |
+
};
|
| 47 |
+
performance: {
|
| 48 |
+
memory: {
|
| 49 |
+
jsHeapSizeLimit: number;
|
| 50 |
+
totalJSHeapSize: number;
|
| 51 |
+
usedJSHeapSize: number;
|
| 52 |
+
usagePercentage: number;
|
| 53 |
+
};
|
| 54 |
+
timing: {
|
| 55 |
+
loadTime: number;
|
| 56 |
+
domReadyTime: number;
|
| 57 |
+
readyStart: number;
|
| 58 |
+
redirectTime: number;
|
| 59 |
+
appcacheTime: number;
|
| 60 |
+
unloadEventTime: number;
|
| 61 |
+
lookupDomainTime: number;
|
| 62 |
+
connectTime: number;
|
| 63 |
+
requestTime: number;
|
| 64 |
+
initDomTreeTime: number;
|
| 65 |
+
loadEventTime: number;
|
| 66 |
+
};
|
| 67 |
+
navigation: {
|
| 68 |
+
type: number;
|
| 69 |
+
redirectCount: number;
|
| 70 |
+
};
|
| 71 |
+
};
|
| 72 |
+
network: {
|
| 73 |
+
downlink: number;
|
| 74 |
+
effectiveType: string;
|
| 75 |
+
rtt: number;
|
| 76 |
+
saveData: boolean;
|
| 77 |
+
type: string;
|
| 78 |
+
};
|
| 79 |
+
battery?: {
|
| 80 |
+
charging: boolean;
|
| 81 |
+
chargingTime: number;
|
| 82 |
+
dischargingTime: number;
|
| 83 |
+
level: number;
|
| 84 |
+
};
|
| 85 |
+
storage: {
|
| 86 |
+
quota: number;
|
| 87 |
+
usage: number;
|
| 88 |
+
persistent: boolean;
|
| 89 |
+
temporary: boolean;
|
| 90 |
+
};
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
interface GitHubRepoInfo {
|
| 94 |
+
fullName: string;
|
| 95 |
+
defaultBranch: string;
|
| 96 |
+
stars: number;
|
| 97 |
+
forks: number;
|
| 98 |
+
openIssues?: number;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
interface GitInfo {
|
| 102 |
+
local: {
|
| 103 |
+
commitHash: string;
|
| 104 |
+
branch: string;
|
| 105 |
+
commitTime: string;
|
| 106 |
+
author: string;
|
| 107 |
+
email: string;
|
| 108 |
+
remoteUrl: string;
|
| 109 |
+
repoName: string;
|
| 110 |
+
};
|
| 111 |
+
github?: {
|
| 112 |
+
currentRepo: GitHubRepoInfo;
|
| 113 |
+
upstream?: GitHubRepoInfo;
|
| 114 |
+
};
|
| 115 |
+
isForked?: boolean;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
interface WebAppInfo {
|
| 119 |
+
name: string;
|
| 120 |
+
version: string;
|
| 121 |
+
description: string;
|
| 122 |
+
license: string;
|
| 123 |
+
environment: string;
|
| 124 |
+
timestamp: string;
|
| 125 |
+
runtimeInfo: {
|
| 126 |
+
nodeVersion: string;
|
| 127 |
+
};
|
| 128 |
+
dependencies: {
|
| 129 |
+
production: Array<{ name: string; version: string; type: string }>;
|
| 130 |
+
development: Array<{ name: string; version: string; type: string }>;
|
| 131 |
+
peer: Array<{ name: string; version: string; type: string }>;
|
| 132 |
+
optional: Array<{ name: string; version: string; type: string }>;
|
| 133 |
+
};
|
| 134 |
+
gitInfo: GitInfo;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Add Ollama service status interface
|
| 138 |
+
interface OllamaServiceStatus {
|
| 139 |
+
isRunning: boolean;
|
| 140 |
+
lastChecked: Date;
|
| 141 |
+
error?: string;
|
| 142 |
+
models?: Array<{
|
| 143 |
+
name: string;
|
| 144 |
+
size: string;
|
| 145 |
+
quantization: string;
|
| 146 |
+
}>;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
interface ExportFormat {
|
| 150 |
+
id: string;
|
| 151 |
+
label: string;
|
| 152 |
+
icon: string;
|
| 153 |
+
handler: () => void;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const DependencySection = ({
|
| 157 |
+
title,
|
| 158 |
+
deps,
|
| 159 |
+
}: {
|
| 160 |
+
title: string;
|
| 161 |
+
deps: Array<{ name: string; version: string; type: string }>;
|
| 162 |
+
}) => {
|
| 163 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 164 |
+
|
| 165 |
+
if (deps.length === 0) {
|
| 166 |
+
return null;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return (
|
| 170 |
+
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
| 171 |
+
<CollapsibleTrigger
|
| 172 |
+
className={classNames(
|
| 173 |
+
'flex w-full items-center justify-between p-4',
|
| 174 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 175 |
+
'hover:bg-purple-50/50 dark:hover:bg-[#1a1a1a]',
|
| 176 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 177 |
+
'transition-colors duration-200',
|
| 178 |
+
'first:rounded-t-lg last:rounded-b-lg',
|
| 179 |
+
{ 'hover:rounded-lg': !isOpen },
|
| 180 |
+
)}
|
| 181 |
+
>
|
| 182 |
+
<div className="flex items-center gap-3">
|
| 183 |
+
<div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
|
| 184 |
+
<span className="text-base text-bolt-elements-textPrimary">
|
| 185 |
+
{title} Dependencies ({deps.length})
|
| 186 |
+
</span>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="flex items-center gap-2">
|
| 189 |
+
<span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
|
| 190 |
+
<div
|
| 191 |
+
className={classNames(
|
| 192 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
| 193 |
+
isOpen ? 'rotate-180' : '',
|
| 194 |
+
)}
|
| 195 |
+
/>
|
| 196 |
+
</div>
|
| 197 |
+
</CollapsibleTrigger>
|
| 198 |
+
<CollapsibleContent>
|
| 199 |
+
<ScrollArea
|
| 200 |
+
className={classNames(
|
| 201 |
+
'h-[200px] w-full',
|
| 202 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 203 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 204 |
+
'last:rounded-b-lg last:border-b-0',
|
| 205 |
+
)}
|
| 206 |
+
>
|
| 207 |
+
<div className="space-y-2 p-4">
|
| 208 |
+
{deps.map((dep) => (
|
| 209 |
+
<div key={dep.name} className="flex items-center justify-between text-sm">
|
| 210 |
+
<span className="text-bolt-elements-textPrimary">{dep.name}</span>
|
| 211 |
+
<span className="text-bolt-elements-textSecondary">{dep.version}</span>
|
| 212 |
+
</div>
|
| 213 |
+
))}
|
| 214 |
+
</div>
|
| 215 |
+
</ScrollArea>
|
| 216 |
+
</CollapsibleContent>
|
| 217 |
+
</Collapsible>
|
| 218 |
+
);
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
export default function DebugTab() {
|
| 222 |
+
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
| 223 |
+
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
|
| 224 |
+
const [ollamaStatus, setOllamaStatus] = useState<OllamaServiceStatus>({
|
| 225 |
+
isRunning: false,
|
| 226 |
+
lastChecked: new Date(),
|
| 227 |
+
});
|
| 228 |
+
const [loading, setLoading] = useState({
|
| 229 |
+
systemInfo: false,
|
| 230 |
+
webAppInfo: false,
|
| 231 |
+
errors: false,
|
| 232 |
+
performance: false,
|
| 233 |
+
});
|
| 234 |
+
const [openSections, setOpenSections] = useState({
|
| 235 |
+
system: false,
|
| 236 |
+
webapp: false,
|
| 237 |
+
errors: false,
|
| 238 |
+
performance: false,
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
const { providers } = useSettings();
|
| 242 |
+
|
| 243 |
+
// Subscribe to logStore updates
|
| 244 |
+
const logs = useStore(logStore.logs);
|
| 245 |
+
const errorLogs = useMemo(() => {
|
| 246 |
+
return Object.values(logs).filter(
|
| 247 |
+
(log): log is LogEntry => typeof log === 'object' && log !== null && 'level' in log && log.level === 'error',
|
| 248 |
+
);
|
| 249 |
+
}, [logs]);
|
| 250 |
+
|
| 251 |
+
// Set up error listeners when component mounts
|
| 252 |
+
useEffect(() => {
|
| 253 |
+
const handleError = (event: ErrorEvent) => {
|
| 254 |
+
logStore.logError(event.message, event.error, {
|
| 255 |
+
filename: event.filename,
|
| 256 |
+
lineNumber: event.lineno,
|
| 257 |
+
columnNumber: event.colno,
|
| 258 |
+
});
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
const handleRejection = (event: PromiseRejectionEvent) => {
|
| 262 |
+
logStore.logError('Unhandled Promise Rejection', event.reason);
|
| 263 |
+
};
|
| 264 |
+
|
| 265 |
+
window.addEventListener('error', handleError);
|
| 266 |
+
window.addEventListener('unhandledrejection', handleRejection);
|
| 267 |
+
|
| 268 |
+
return () => {
|
| 269 |
+
window.removeEventListener('error', handleError);
|
| 270 |
+
window.removeEventListener('unhandledrejection', handleRejection);
|
| 271 |
+
};
|
| 272 |
+
}, []);
|
| 273 |
+
|
| 274 |
+
// Check for errors when the errors section is opened
|
| 275 |
+
useEffect(() => {
|
| 276 |
+
if (openSections.errors) {
|
| 277 |
+
checkErrors();
|
| 278 |
+
}
|
| 279 |
+
}, [openSections.errors]);
|
| 280 |
+
|
| 281 |
+
// Load initial data when component mounts
|
| 282 |
+
useEffect(() => {
|
| 283 |
+
const loadInitialData = async () => {
|
| 284 |
+
await Promise.all([getSystemInfo(), getWebAppInfo()]);
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
loadInitialData();
|
| 288 |
+
}, []);
|
| 289 |
+
|
| 290 |
+
// Refresh data when sections are opened
|
| 291 |
+
useEffect(() => {
|
| 292 |
+
if (openSections.system) {
|
| 293 |
+
getSystemInfo();
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
if (openSections.webapp) {
|
| 297 |
+
getWebAppInfo();
|
| 298 |
+
}
|
| 299 |
+
}, [openSections.system, openSections.webapp]);
|
| 300 |
+
|
| 301 |
+
// Add periodic refresh of git info
|
| 302 |
+
useEffect(() => {
|
| 303 |
+
if (!openSections.webapp) {
|
| 304 |
+
return undefined;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Initial fetch
|
| 308 |
+
const fetchGitInfo = async () => {
|
| 309 |
+
try {
|
| 310 |
+
const response = await fetch('/api/system/git-info');
|
| 311 |
+
const updatedGitInfo = (await response.json()) as GitInfo;
|
| 312 |
+
|
| 313 |
+
setWebAppInfo((prev) => {
|
| 314 |
+
if (!prev) {
|
| 315 |
+
return null;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Only update if the data has changed
|
| 319 |
+
if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) {
|
| 320 |
+
return prev;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
return {
|
| 324 |
+
...prev,
|
| 325 |
+
gitInfo: updatedGitInfo,
|
| 326 |
+
};
|
| 327 |
+
});
|
| 328 |
+
} catch (error) {
|
| 329 |
+
console.error('Failed to fetch git info:', error);
|
| 330 |
+
}
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
fetchGitInfo();
|
| 334 |
+
|
| 335 |
+
// Refresh every 5 minutes instead of every second
|
| 336 |
+
const interval = setInterval(fetchGitInfo, 5 * 60 * 1000);
|
| 337 |
+
|
| 338 |
+
return () => clearInterval(interval);
|
| 339 |
+
}, [openSections.webapp]);
|
| 340 |
+
|
| 341 |
+
const getSystemInfo = async () => {
|
| 342 |
+
try {
|
| 343 |
+
setLoading((prev) => ({ ...prev, systemInfo: true }));
|
| 344 |
+
|
| 345 |
+
// Get browser info
|
| 346 |
+
const ua = navigator.userAgent;
|
| 347 |
+
const browserName = ua.includes('Firefox')
|
| 348 |
+
? 'Firefox'
|
| 349 |
+
: ua.includes('Chrome')
|
| 350 |
+
? 'Chrome'
|
| 351 |
+
: ua.includes('Safari')
|
| 352 |
+
? 'Safari'
|
| 353 |
+
: ua.includes('Edge')
|
| 354 |
+
? 'Edge'
|
| 355 |
+
: 'Unknown';
|
| 356 |
+
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
|
| 357 |
+
|
| 358 |
+
// Get performance metrics
|
| 359 |
+
const memory = (performance as any).memory || {};
|
| 360 |
+
const timing = performance.timing;
|
| 361 |
+
const navigation = performance.navigation;
|
| 362 |
+
const connection = (navigator as any).connection;
|
| 363 |
+
|
| 364 |
+
// Get battery info
|
| 365 |
+
let batteryInfo;
|
| 366 |
+
|
| 367 |
+
try {
|
| 368 |
+
const battery = await (navigator as any).getBattery();
|
| 369 |
+
batteryInfo = {
|
| 370 |
+
charging: battery.charging,
|
| 371 |
+
chargingTime: battery.chargingTime,
|
| 372 |
+
dischargingTime: battery.dischargingTime,
|
| 373 |
+
level: battery.level * 100,
|
| 374 |
+
};
|
| 375 |
+
} catch {
|
| 376 |
+
console.log('Battery API not supported');
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// Get storage info
|
| 380 |
+
let storageInfo = {
|
| 381 |
+
quota: 0,
|
| 382 |
+
usage: 0,
|
| 383 |
+
persistent: false,
|
| 384 |
+
temporary: false,
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
try {
|
| 388 |
+
const storage = await navigator.storage.estimate();
|
| 389 |
+
const persistent = await navigator.storage.persist();
|
| 390 |
+
storageInfo = {
|
| 391 |
+
quota: storage.quota || 0,
|
| 392 |
+
usage: storage.usage || 0,
|
| 393 |
+
persistent,
|
| 394 |
+
temporary: !persistent,
|
| 395 |
+
};
|
| 396 |
+
} catch {
|
| 397 |
+
console.log('Storage API not supported');
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// Get memory info from browser performance API
|
| 401 |
+
const performanceMemory = (performance as any).memory || {};
|
| 402 |
+
const totalMemory = performanceMemory.jsHeapSizeLimit || 0;
|
| 403 |
+
const usedMemory = performanceMemory.usedJSHeapSize || 0;
|
| 404 |
+
const freeMemory = totalMemory - usedMemory;
|
| 405 |
+
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
|
| 406 |
+
|
| 407 |
+
const systemInfo: SystemInfo = {
|
| 408 |
+
os: navigator.platform,
|
| 409 |
+
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
|
| 410 |
+
platform: navigator.platform,
|
| 411 |
+
cpus: navigator.hardwareConcurrency + ' cores',
|
| 412 |
+
memory: {
|
| 413 |
+
total: formatBytes(totalMemory),
|
| 414 |
+
free: formatBytes(freeMemory),
|
| 415 |
+
used: formatBytes(usedMemory),
|
| 416 |
+
percentage: Math.round(memoryPercentage),
|
| 417 |
+
},
|
| 418 |
+
node: 'browser',
|
| 419 |
+
browser: {
|
| 420 |
+
name: browserName,
|
| 421 |
+
version: browserVersion,
|
| 422 |
+
language: navigator.language,
|
| 423 |
+
userAgent: navigator.userAgent,
|
| 424 |
+
cookiesEnabled: navigator.cookieEnabled,
|
| 425 |
+
online: navigator.onLine,
|
| 426 |
+
platform: navigator.platform,
|
| 427 |
+
cores: navigator.hardwareConcurrency,
|
| 428 |
+
},
|
| 429 |
+
screen: {
|
| 430 |
+
width: window.screen.width,
|
| 431 |
+
height: window.screen.height,
|
| 432 |
+
colorDepth: window.screen.colorDepth,
|
| 433 |
+
pixelRatio: window.devicePixelRatio,
|
| 434 |
+
},
|
| 435 |
+
time: {
|
| 436 |
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
| 437 |
+
offset: new Date().getTimezoneOffset(),
|
| 438 |
+
locale: navigator.language,
|
| 439 |
+
},
|
| 440 |
+
performance: {
|
| 441 |
+
memory: {
|
| 442 |
+
jsHeapSizeLimit: memory.jsHeapSizeLimit || 0,
|
| 443 |
+
totalJSHeapSize: memory.totalJSHeapSize || 0,
|
| 444 |
+
usedJSHeapSize: memory.usedJSHeapSize || 0,
|
| 445 |
+
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
|
| 446 |
+
},
|
| 447 |
+
timing: {
|
| 448 |
+
loadTime: timing.loadEventEnd - timing.navigationStart,
|
| 449 |
+
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
|
| 450 |
+
readyStart: timing.fetchStart - timing.navigationStart,
|
| 451 |
+
redirectTime: timing.redirectEnd - timing.redirectStart,
|
| 452 |
+
appcacheTime: timing.domainLookupStart - timing.fetchStart,
|
| 453 |
+
unloadEventTime: timing.unloadEventEnd - timing.unloadEventStart,
|
| 454 |
+
lookupDomainTime: timing.domainLookupEnd - timing.domainLookupStart,
|
| 455 |
+
connectTime: timing.connectEnd - timing.connectStart,
|
| 456 |
+
requestTime: timing.responseEnd - timing.requestStart,
|
| 457 |
+
initDomTreeTime: timing.domInteractive - timing.responseEnd,
|
| 458 |
+
loadEventTime: timing.loadEventEnd - timing.loadEventStart,
|
| 459 |
+
},
|
| 460 |
+
navigation: {
|
| 461 |
+
type: navigation.type,
|
| 462 |
+
redirectCount: navigation.redirectCount,
|
| 463 |
+
},
|
| 464 |
+
},
|
| 465 |
+
network: {
|
| 466 |
+
downlink: connection?.downlink || 0,
|
| 467 |
+
effectiveType: connection?.effectiveType || 'unknown',
|
| 468 |
+
rtt: connection?.rtt || 0,
|
| 469 |
+
saveData: connection?.saveData || false,
|
| 470 |
+
type: connection?.type || 'unknown',
|
| 471 |
+
},
|
| 472 |
+
battery: batteryInfo,
|
| 473 |
+
storage: storageInfo,
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
setSystemInfo(systemInfo);
|
| 477 |
+
toast.success('System information updated');
|
| 478 |
+
} catch (error) {
|
| 479 |
+
toast.error('Failed to get system information');
|
| 480 |
+
console.error('Failed to get system information:', error);
|
| 481 |
+
} finally {
|
| 482 |
+
setLoading((prev) => ({ ...prev, systemInfo: false }));
|
| 483 |
+
}
|
| 484 |
+
};
|
| 485 |
+
|
| 486 |
+
const getWebAppInfo = async () => {
|
| 487 |
+
try {
|
| 488 |
+
setLoading((prev) => ({ ...prev, webAppInfo: true }));
|
| 489 |
+
|
| 490 |
+
const [appResponse, gitResponse] = await Promise.all([
|
| 491 |
+
fetch('/api/system/app-info'),
|
| 492 |
+
fetch('/api/system/git-info'),
|
| 493 |
+
]);
|
| 494 |
+
|
| 495 |
+
if (!appResponse.ok || !gitResponse.ok) {
|
| 496 |
+
throw new Error('Failed to fetch webapp info');
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
const appData = (await appResponse.json()) as Omit<WebAppInfo, 'gitInfo'>;
|
| 500 |
+
const gitData = (await gitResponse.json()) as GitInfo;
|
| 501 |
+
|
| 502 |
+
console.log('Git Info Response:', gitData); // Add logging to debug
|
| 503 |
+
|
| 504 |
+
setWebAppInfo({
|
| 505 |
+
...appData,
|
| 506 |
+
gitInfo: gitData,
|
| 507 |
+
});
|
| 508 |
+
|
| 509 |
+
toast.success('WebApp information updated');
|
| 510 |
+
|
| 511 |
+
return true;
|
| 512 |
+
} catch (error) {
|
| 513 |
+
console.error('Failed to fetch webapp info:', error);
|
| 514 |
+
toast.error('Failed to fetch webapp information');
|
| 515 |
+
setWebAppInfo(null);
|
| 516 |
+
|
| 517 |
+
return false;
|
| 518 |
+
} finally {
|
| 519 |
+
setLoading((prev) => ({ ...prev, webAppInfo: false }));
|
| 520 |
+
}
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
// Helper function to format bytes to human readable format
|
| 524 |
+
const formatBytes = (bytes: number) => {
|
| 525 |
+
const units = ['B', 'KB', 'MB', 'GB'];
|
| 526 |
+
let size = bytes;
|
| 527 |
+
let unitIndex = 0;
|
| 528 |
+
|
| 529 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
| 530 |
+
size /= 1024;
|
| 531 |
+
unitIndex++;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
return `${Math.round(size)} ${units[unitIndex]}`;
|
| 535 |
+
};
|
| 536 |
+
|
| 537 |
+
const handleLogPerformance = () => {
|
| 538 |
+
try {
|
| 539 |
+
setLoading((prev) => ({ ...prev, performance: true }));
|
| 540 |
+
|
| 541 |
+
// Get performance metrics using modern Performance API
|
| 542 |
+
const performanceEntries = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
| 543 |
+
const memory = (performance as any).memory;
|
| 544 |
+
|
| 545 |
+
// Calculate timing metrics
|
| 546 |
+
const timingMetrics = {
|
| 547 |
+
loadTime: performanceEntries.loadEventEnd - performanceEntries.startTime,
|
| 548 |
+
domReadyTime: performanceEntries.domContentLoadedEventEnd - performanceEntries.startTime,
|
| 549 |
+
fetchTime: performanceEntries.responseEnd - performanceEntries.fetchStart,
|
| 550 |
+
redirectTime: performanceEntries.redirectEnd - performanceEntries.redirectStart,
|
| 551 |
+
dnsTime: performanceEntries.domainLookupEnd - performanceEntries.domainLookupStart,
|
| 552 |
+
tcpTime: performanceEntries.connectEnd - performanceEntries.connectStart,
|
| 553 |
+
ttfb: performanceEntries.responseStart - performanceEntries.requestStart,
|
| 554 |
+
processingTime: performanceEntries.loadEventEnd - performanceEntries.responseEnd,
|
| 555 |
+
};
|
| 556 |
+
|
| 557 |
+
// Get resource timing data
|
| 558 |
+
const resourceEntries = performance.getEntriesByType('resource');
|
| 559 |
+
const resourceStats = {
|
| 560 |
+
totalResources: resourceEntries.length,
|
| 561 |
+
totalSize: resourceEntries.reduce((total, entry) => total + ((entry as any).transferSize || 0), 0),
|
| 562 |
+
totalTime: Math.max(...resourceEntries.map((entry) => entry.duration)),
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
// Get memory metrics
|
| 566 |
+
const memoryMetrics = memory
|
| 567 |
+
? {
|
| 568 |
+
jsHeapSizeLimit: memory.jsHeapSizeLimit,
|
| 569 |
+
totalJSHeapSize: memory.totalJSHeapSize,
|
| 570 |
+
usedJSHeapSize: memory.usedJSHeapSize,
|
| 571 |
+
heapUtilization: (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100,
|
| 572 |
+
}
|
| 573 |
+
: null;
|
| 574 |
+
|
| 575 |
+
// Get frame rate metrics
|
| 576 |
+
let fps = 0;
|
| 577 |
+
|
| 578 |
+
if ('requestAnimationFrame' in window) {
|
| 579 |
+
const times: number[] = [];
|
| 580 |
+
|
| 581 |
+
function calculateFPS(now: number) {
|
| 582 |
+
times.push(now);
|
| 583 |
+
|
| 584 |
+
if (times.length > 10) {
|
| 585 |
+
const fps = Math.round((1000 * 10) / (now - times[0]));
|
| 586 |
+
times.shift();
|
| 587 |
+
|
| 588 |
+
return fps;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
requestAnimationFrame(calculateFPS);
|
| 592 |
+
|
| 593 |
+
return 0;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
fps = calculateFPS(performance.now());
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
// Log all performance metrics
|
| 600 |
+
logStore.logSystem('Performance Metrics', {
|
| 601 |
+
timing: timingMetrics,
|
| 602 |
+
resources: resourceStats,
|
| 603 |
+
memory: memoryMetrics,
|
| 604 |
+
fps,
|
| 605 |
+
timestamp: new Date().toISOString(),
|
| 606 |
+
navigationEntry: {
|
| 607 |
+
type: performanceEntries.type,
|
| 608 |
+
redirectCount: performanceEntries.redirectCount,
|
| 609 |
+
},
|
| 610 |
+
});
|
| 611 |
+
|
| 612 |
+
toast.success('Performance metrics logged');
|
| 613 |
+
} catch (error) {
|
| 614 |
+
toast.error('Failed to log performance metrics');
|
| 615 |
+
console.error('Failed to log performance metrics:', error);
|
| 616 |
+
} finally {
|
| 617 |
+
setLoading((prev) => ({ ...prev, performance: false }));
|
| 618 |
+
}
|
| 619 |
+
};
|
| 620 |
+
|
| 621 |
+
const checkErrors = async () => {
|
| 622 |
+
try {
|
| 623 |
+
setLoading((prev) => ({ ...prev, errors: true }));
|
| 624 |
+
|
| 625 |
+
// Get errors from log store
|
| 626 |
+
const storedErrors = errorLogs;
|
| 627 |
+
|
| 628 |
+
if (storedErrors.length === 0) {
|
| 629 |
+
toast.success('No errors found');
|
| 630 |
+
} else {
|
| 631 |
+
toast.warning(`Found ${storedErrors.length} error(s)`);
|
| 632 |
+
}
|
| 633 |
+
} catch (error) {
|
| 634 |
+
toast.error('Failed to check errors');
|
| 635 |
+
console.error('Failed to check errors:', error);
|
| 636 |
+
} finally {
|
| 637 |
+
setLoading((prev) => ({ ...prev, errors: false }));
|
| 638 |
+
}
|
| 639 |
+
};
|
| 640 |
+
|
| 641 |
+
const exportDebugInfo = () => {
|
| 642 |
+
try {
|
| 643 |
+
const debugData = {
|
| 644 |
+
timestamp: new Date().toISOString(),
|
| 645 |
+
system: systemInfo,
|
| 646 |
+
webApp: webAppInfo,
|
| 647 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
| 648 |
+
performance: {
|
| 649 |
+
memory: (performance as any).memory || {},
|
| 650 |
+
timing: performance.timing,
|
| 651 |
+
navigation: performance.navigation,
|
| 652 |
+
},
|
| 653 |
+
};
|
| 654 |
+
|
| 655 |
+
const blob = new Blob([JSON.stringify(debugData, null, 2)], { type: 'application/json' });
|
| 656 |
+
const url = window.URL.createObjectURL(blob);
|
| 657 |
+
const a = document.createElement('a');
|
| 658 |
+
a.href = url;
|
| 659 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.json`;
|
| 660 |
+
document.body.appendChild(a);
|
| 661 |
+
a.click();
|
| 662 |
+
window.URL.revokeObjectURL(url);
|
| 663 |
+
document.body.removeChild(a);
|
| 664 |
+
toast.success('Debug information exported successfully');
|
| 665 |
+
} catch (error) {
|
| 666 |
+
console.error('Failed to export debug info:', error);
|
| 667 |
+
toast.error('Failed to export debug information');
|
| 668 |
+
}
|
| 669 |
+
};
|
| 670 |
+
|
| 671 |
+
const exportAsCSV = () => {
|
| 672 |
+
try {
|
| 673 |
+
const debugData = {
|
| 674 |
+
system: systemInfo,
|
| 675 |
+
webApp: webAppInfo,
|
| 676 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
| 677 |
+
performance: {
|
| 678 |
+
memory: (performance as any).memory || {},
|
| 679 |
+
timing: performance.timing,
|
| 680 |
+
navigation: performance.navigation,
|
| 681 |
+
},
|
| 682 |
+
};
|
| 683 |
+
|
| 684 |
+
// Convert the data to CSV format
|
| 685 |
+
const csvData = [
|
| 686 |
+
['Category', 'Key', 'Value'],
|
| 687 |
+
...Object.entries(debugData).flatMap(([category, data]) =>
|
| 688 |
+
Object.entries(data || {}).map(([key, value]) => [
|
| 689 |
+
category,
|
| 690 |
+
key,
|
| 691 |
+
typeof value === 'object' ? JSON.stringify(value) : String(value),
|
| 692 |
+
]),
|
| 693 |
+
),
|
| 694 |
+
];
|
| 695 |
+
|
| 696 |
+
// Create CSV content
|
| 697 |
+
const csvContent = csvData.map((row) => row.join(',')).join('\n');
|
| 698 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
| 699 |
+
const url = window.URL.createObjectURL(blob);
|
| 700 |
+
const a = document.createElement('a');
|
| 701 |
+
a.href = url;
|
| 702 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.csv`;
|
| 703 |
+
document.body.appendChild(a);
|
| 704 |
+
a.click();
|
| 705 |
+
window.URL.revokeObjectURL(url);
|
| 706 |
+
document.body.removeChild(a);
|
| 707 |
+
toast.success('Debug information exported as CSV');
|
| 708 |
+
} catch (error) {
|
| 709 |
+
console.error('Failed to export CSV:', error);
|
| 710 |
+
toast.error('Failed to export debug information as CSV');
|
| 711 |
+
}
|
| 712 |
+
};
|
| 713 |
+
|
| 714 |
+
const exportAsPDF = () => {
|
| 715 |
+
try {
|
| 716 |
+
const debugData = {
|
| 717 |
+
system: systemInfo,
|
| 718 |
+
webApp: webAppInfo,
|
| 719 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
| 720 |
+
performance: {
|
| 721 |
+
memory: (performance as any).memory || {},
|
| 722 |
+
timing: performance.timing,
|
| 723 |
+
navigation: performance.navigation,
|
| 724 |
+
},
|
| 725 |
+
};
|
| 726 |
+
|
| 727 |
+
// Create new PDF document
|
| 728 |
+
const doc = new jsPDF();
|
| 729 |
+
const lineHeight = 7;
|
| 730 |
+
let yPos = 20;
|
| 731 |
+
const margin = 20;
|
| 732 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
| 733 |
+
const maxLineWidth = pageWidth - 2 * margin;
|
| 734 |
+
|
| 735 |
+
// Add key-value pair with better formatting
|
| 736 |
+
const addKeyValue = (key: string, value: any, indent = 0) => {
|
| 737 |
+
// Check if we need a new page
|
| 738 |
+
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
| 739 |
+
doc.addPage();
|
| 740 |
+
yPos = margin;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
doc.setFontSize(10);
|
| 744 |
+
doc.setTextColor('#374151');
|
| 745 |
+
doc.setFont('helvetica', 'bold');
|
| 746 |
+
|
| 747 |
+
// Format the key with proper spacing
|
| 748 |
+
const formattedKey = key.replace(/([A-Z])/g, ' $1').trim();
|
| 749 |
+
doc.text(formattedKey + ':', margin + indent, yPos);
|
| 750 |
+
doc.setFont('helvetica', 'normal');
|
| 751 |
+
doc.setTextColor('#6B7280');
|
| 752 |
+
|
| 753 |
+
let valueText;
|
| 754 |
+
|
| 755 |
+
if (typeof value === 'object' && value !== null) {
|
| 756 |
+
// Skip rendering if value is empty object
|
| 757 |
+
if (Object.keys(value).length === 0) {
|
| 758 |
+
return;
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
yPos += lineHeight;
|
| 762 |
+
Object.entries(value).forEach(([subKey, subValue]) => {
|
| 763 |
+
// Check for page break before each sub-item
|
| 764 |
+
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
| 765 |
+
doc.addPage();
|
| 766 |
+
yPos = margin;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
const formattedSubKey = subKey.replace(/([A-Z])/g, ' $1').trim();
|
| 770 |
+
addKeyValue(formattedSubKey, subValue, indent + 10);
|
| 771 |
+
});
|
| 772 |
+
|
| 773 |
+
return;
|
| 774 |
+
} else {
|
| 775 |
+
valueText = String(value);
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
const valueX = margin + indent + doc.getTextWidth(formattedKey + ': ');
|
| 779 |
+
const maxValueWidth = maxLineWidth - indent - doc.getTextWidth(formattedKey + ': ');
|
| 780 |
+
const lines = doc.splitTextToSize(valueText, maxValueWidth);
|
| 781 |
+
|
| 782 |
+
// Check if we need a new page for the value
|
| 783 |
+
if (yPos + lines.length * lineHeight > doc.internal.pageSize.getHeight() - 20) {
|
| 784 |
+
doc.addPage();
|
| 785 |
+
yPos = margin;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
doc.text(lines, valueX, yPos);
|
| 789 |
+
yPos += lines.length * lineHeight;
|
| 790 |
+
};
|
| 791 |
+
|
| 792 |
+
// Add section header with page break check
|
| 793 |
+
const addSectionHeader = (title: string) => {
|
| 794 |
+
// Check if we need a new page
|
| 795 |
+
if (yPos + 20 > doc.internal.pageSize.getHeight() - 20) {
|
| 796 |
+
doc.addPage();
|
| 797 |
+
yPos = margin;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
yPos += lineHeight;
|
| 801 |
+
doc.setFillColor('#F3F4F6');
|
| 802 |
+
doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
|
| 803 |
+
doc.setFont('helvetica', 'bold');
|
| 804 |
+
doc.setTextColor('#111827');
|
| 805 |
+
doc.setFontSize(12);
|
| 806 |
+
doc.text(title.toUpperCase(), margin, yPos);
|
| 807 |
+
doc.setFont('helvetica', 'normal');
|
| 808 |
+
yPos += lineHeight * 1.5;
|
| 809 |
+
};
|
| 810 |
+
|
| 811 |
+
// Add horizontal line with page break check
|
| 812 |
+
const addHorizontalLine = () => {
|
| 813 |
+
// Check if we need a new page
|
| 814 |
+
if (yPos + 10 > doc.internal.pageSize.getHeight() - 20) {
|
| 815 |
+
doc.addPage();
|
| 816 |
+
yPos = margin;
|
| 817 |
+
|
| 818 |
+
return; // Skip drawing line if we just started a new page
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
doc.setDrawColor('#E5E5E5');
|
| 822 |
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
| 823 |
+
yPos += lineHeight;
|
| 824 |
+
};
|
| 825 |
+
|
| 826 |
+
// Helper function to add footer to all pages
|
| 827 |
+
const addFooters = () => {
|
| 828 |
+
const totalPages = doc.internal.pages.length - 1;
|
| 829 |
+
|
| 830 |
+
for (let i = 1; i <= totalPages; i++) {
|
| 831 |
+
doc.setPage(i);
|
| 832 |
+
doc.setFontSize(8);
|
| 833 |
+
doc.setTextColor('#9CA3AF');
|
| 834 |
+
doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
|
| 835 |
+
align: 'center',
|
| 836 |
+
});
|
| 837 |
+
}
|
| 838 |
+
};
|
| 839 |
+
|
| 840 |
+
// Title and Header (first page only)
|
| 841 |
+
doc.setFillColor('#6366F1');
|
| 842 |
+
doc.rect(0, 0, pageWidth, 40, 'F');
|
| 843 |
+
doc.setTextColor('#FFFFFF');
|
| 844 |
+
doc.setFontSize(24);
|
| 845 |
+
doc.setFont('helvetica', 'bold');
|
| 846 |
+
doc.text('Debug Information Report', margin, 25);
|
| 847 |
+
yPos = 50;
|
| 848 |
+
|
| 849 |
+
// Timestamp and metadata
|
| 850 |
+
doc.setTextColor('#6B7280');
|
| 851 |
+
doc.setFontSize(10);
|
| 852 |
+
doc.setFont('helvetica', 'normal');
|
| 853 |
+
|
| 854 |
+
const timestamp = new Date().toLocaleString(undefined, {
|
| 855 |
+
year: 'numeric',
|
| 856 |
+
month: '2-digit',
|
| 857 |
+
day: '2-digit',
|
| 858 |
+
hour: '2-digit',
|
| 859 |
+
minute: '2-digit',
|
| 860 |
+
second: '2-digit',
|
| 861 |
+
});
|
| 862 |
+
doc.text(`Generated: ${timestamp}`, margin, yPos);
|
| 863 |
+
yPos += lineHeight * 2;
|
| 864 |
+
|
| 865 |
+
// System Information Section
|
| 866 |
+
if (debugData.system) {
|
| 867 |
+
addSectionHeader('System Information');
|
| 868 |
+
|
| 869 |
+
// OS and Architecture
|
| 870 |
+
addKeyValue('Operating System', debugData.system.os);
|
| 871 |
+
addKeyValue('Architecture', debugData.system.arch);
|
| 872 |
+
addKeyValue('Platform', debugData.system.platform);
|
| 873 |
+
addKeyValue('CPU Cores', debugData.system.cpus);
|
| 874 |
+
|
| 875 |
+
// Memory
|
| 876 |
+
const memory = debugData.system.memory;
|
| 877 |
+
addKeyValue('Memory', {
|
| 878 |
+
'Total Memory': memory.total,
|
| 879 |
+
'Used Memory': memory.used,
|
| 880 |
+
'Free Memory': memory.free,
|
| 881 |
+
Usage: memory.percentage + '%',
|
| 882 |
+
});
|
| 883 |
+
|
| 884 |
+
// Browser Information
|
| 885 |
+
const browser = debugData.system.browser;
|
| 886 |
+
addKeyValue('Browser', {
|
| 887 |
+
Name: browser.name,
|
| 888 |
+
Version: browser.version,
|
| 889 |
+
Language: browser.language,
|
| 890 |
+
Platform: browser.platform,
|
| 891 |
+
'Cookies Enabled': browser.cookiesEnabled ? 'Yes' : 'No',
|
| 892 |
+
'Online Status': browser.online ? 'Online' : 'Offline',
|
| 893 |
+
});
|
| 894 |
+
|
| 895 |
+
// Screen Information
|
| 896 |
+
const screen = debugData.system.screen;
|
| 897 |
+
addKeyValue('Screen', {
|
| 898 |
+
Resolution: `${screen.width}x${screen.height}`,
|
| 899 |
+
'Color Depth': screen.colorDepth + ' bit',
|
| 900 |
+
'Pixel Ratio': screen.pixelRatio + 'x',
|
| 901 |
+
});
|
| 902 |
+
|
| 903 |
+
// Time Information
|
| 904 |
+
const time = debugData.system.time;
|
| 905 |
+
addKeyValue('Time Settings', {
|
| 906 |
+
Timezone: time.timezone,
|
| 907 |
+
'UTC Offset': time.offset / 60 + ' hours',
|
| 908 |
+
Locale: time.locale,
|
| 909 |
+
});
|
| 910 |
+
|
| 911 |
+
addHorizontalLine();
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
// Web App Information Section
|
| 915 |
+
if (debugData.webApp) {
|
| 916 |
+
addSectionHeader('Web App Information');
|
| 917 |
+
|
| 918 |
+
// Basic Info
|
| 919 |
+
addKeyValue('Application', {
|
| 920 |
+
Name: debugData.webApp.name,
|
| 921 |
+
Version: debugData.webApp.version,
|
| 922 |
+
Environment: debugData.webApp.environment,
|
| 923 |
+
'Node Version': debugData.webApp.runtimeInfo.nodeVersion,
|
| 924 |
+
});
|
| 925 |
+
|
| 926 |
+
// Git Information
|
| 927 |
+
if (debugData.webApp.gitInfo) {
|
| 928 |
+
const gitInfo = debugData.webApp.gitInfo.local;
|
| 929 |
+
addKeyValue('Git Information', {
|
| 930 |
+
Branch: gitInfo.branch,
|
| 931 |
+
Commit: gitInfo.commitHash,
|
| 932 |
+
Author: gitInfo.author,
|
| 933 |
+
'Commit Time': gitInfo.commitTime,
|
| 934 |
+
Repository: gitInfo.repoName,
|
| 935 |
+
});
|
| 936 |
+
|
| 937 |
+
if (debugData.webApp.gitInfo.github) {
|
| 938 |
+
const githubInfo = debugData.webApp.gitInfo.github.currentRepo;
|
| 939 |
+
addKeyValue('GitHub Information', {
|
| 940 |
+
Repository: githubInfo.fullName,
|
| 941 |
+
'Default Branch': githubInfo.defaultBranch,
|
| 942 |
+
Stars: githubInfo.stars,
|
| 943 |
+
Forks: githubInfo.forks,
|
| 944 |
+
'Open Issues': githubInfo.openIssues || 0,
|
| 945 |
+
});
|
| 946 |
+
}
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
addHorizontalLine();
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
// Performance Section
|
| 953 |
+
if (debugData.performance) {
|
| 954 |
+
addSectionHeader('Performance Metrics');
|
| 955 |
+
|
| 956 |
+
// Memory Usage
|
| 957 |
+
const memory = debugData.performance.memory || {};
|
| 958 |
+
const totalHeap = memory.totalJSHeapSize || 0;
|
| 959 |
+
const usedHeap = memory.usedJSHeapSize || 0;
|
| 960 |
+
const usagePercentage = memory.usagePercentage || 0;
|
| 961 |
+
|
| 962 |
+
addKeyValue('Memory Usage', {
|
| 963 |
+
'Total Heap Size': formatBytes(totalHeap),
|
| 964 |
+
'Used Heap Size': formatBytes(usedHeap),
|
| 965 |
+
Usage: usagePercentage.toFixed(1) + '%',
|
| 966 |
+
});
|
| 967 |
+
|
| 968 |
+
// Timing Metrics
|
| 969 |
+
const timing = debugData.performance.timing || {};
|
| 970 |
+
const navigationStart = timing.navigationStart || 0;
|
| 971 |
+
const loadEventEnd = timing.loadEventEnd || 0;
|
| 972 |
+
const domContentLoadedEventEnd = timing.domContentLoadedEventEnd || 0;
|
| 973 |
+
const responseEnd = timing.responseEnd || 0;
|
| 974 |
+
const requestStart = timing.requestStart || 0;
|
| 975 |
+
|
| 976 |
+
const loadTime = loadEventEnd > navigationStart ? loadEventEnd - navigationStart : 0;
|
| 977 |
+
const domReadyTime =
|
| 978 |
+
domContentLoadedEventEnd > navigationStart ? domContentLoadedEventEnd - navigationStart : 0;
|
| 979 |
+
const requestTime = responseEnd > requestStart ? responseEnd - requestStart : 0;
|
| 980 |
+
|
| 981 |
+
addKeyValue('Page Load Metrics', {
|
| 982 |
+
'Total Load Time': (loadTime / 1000).toFixed(2) + ' seconds',
|
| 983 |
+
'DOM Ready Time': (domReadyTime / 1000).toFixed(2) + ' seconds',
|
| 984 |
+
'Request Time': (requestTime / 1000).toFixed(2) + ' seconds',
|
| 985 |
+
});
|
| 986 |
+
|
| 987 |
+
// Network Information
|
| 988 |
+
if (debugData.system?.network) {
|
| 989 |
+
const network = debugData.system.network;
|
| 990 |
+
addKeyValue('Network Information', {
|
| 991 |
+
'Connection Type': network.type || 'Unknown',
|
| 992 |
+
'Effective Type': network.effectiveType || 'Unknown',
|
| 993 |
+
'Download Speed': (network.downlink || 0) + ' Mbps',
|
| 994 |
+
'Latency (RTT)': (network.rtt || 0) + ' ms',
|
| 995 |
+
'Data Saver': network.saveData ? 'Enabled' : 'Disabled',
|
| 996 |
+
});
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
addHorizontalLine();
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
// Errors Section
|
| 1003 |
+
if (debugData.errors && debugData.errors.length > 0) {
|
| 1004 |
+
addSectionHeader('Error Log');
|
| 1005 |
+
|
| 1006 |
+
debugData.errors.forEach((error: LogEntry, index: number) => {
|
| 1007 |
+
doc.setTextColor('#DC2626');
|
| 1008 |
+
doc.setFontSize(10);
|
| 1009 |
+
doc.setFont('helvetica', 'bold');
|
| 1010 |
+
doc.text(`Error ${index + 1}:`, margin, yPos);
|
| 1011 |
+
yPos += lineHeight;
|
| 1012 |
+
|
| 1013 |
+
doc.setFont('helvetica', 'normal');
|
| 1014 |
+
doc.setTextColor('#6B7280');
|
| 1015 |
+
addKeyValue('Message', error.message, 10);
|
| 1016 |
+
|
| 1017 |
+
if (error.stack) {
|
| 1018 |
+
addKeyValue('Stack', error.stack, 10);
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
if (error.source) {
|
| 1022 |
+
addKeyValue('Source', error.source, 10);
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
yPos += lineHeight;
|
| 1026 |
+
});
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
// Add footers to all pages at the end
|
| 1030 |
+
addFooters();
|
| 1031 |
+
|
| 1032 |
+
// Save the PDF
|
| 1033 |
+
doc.save(`bolt-debug-info-${new Date().toISOString()}.pdf`);
|
| 1034 |
+
toast.success('Debug information exported as PDF');
|
| 1035 |
+
} catch (error) {
|
| 1036 |
+
console.error('Failed to export PDF:', error);
|
| 1037 |
+
toast.error('Failed to export debug information as PDF');
|
| 1038 |
+
}
|
| 1039 |
+
};
|
| 1040 |
+
|
| 1041 |
+
const exportAsText = () => {
|
| 1042 |
+
try {
|
| 1043 |
+
const debugData = {
|
| 1044 |
+
system: systemInfo,
|
| 1045 |
+
webApp: webAppInfo,
|
| 1046 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
| 1047 |
+
performance: {
|
| 1048 |
+
memory: (performance as any).memory || {},
|
| 1049 |
+
timing: performance.timing,
|
| 1050 |
+
navigation: performance.navigation,
|
| 1051 |
+
},
|
| 1052 |
+
};
|
| 1053 |
+
|
| 1054 |
+
const textContent = Object.entries(debugData)
|
| 1055 |
+
.map(([category, data]) => {
|
| 1056 |
+
return `${category.toUpperCase()}\n${'-'.repeat(30)}\n${JSON.stringify(data, null, 2)}\n\n`;
|
| 1057 |
+
})
|
| 1058 |
+
.join('\n');
|
| 1059 |
+
|
| 1060 |
+
const blob = new Blob([textContent], { type: 'text/plain' });
|
| 1061 |
+
const url = window.URL.createObjectURL(blob);
|
| 1062 |
+
const a = document.createElement('a');
|
| 1063 |
+
a.href = url;
|
| 1064 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.txt`;
|
| 1065 |
+
document.body.appendChild(a);
|
| 1066 |
+
a.click();
|
| 1067 |
+
window.URL.revokeObjectURL(url);
|
| 1068 |
+
document.body.removeChild(a);
|
| 1069 |
+
toast.success('Debug information exported as text file');
|
| 1070 |
+
} catch (error) {
|
| 1071 |
+
console.error('Failed to export text file:', error);
|
| 1072 |
+
toast.error('Failed to export debug information as text file');
|
| 1073 |
+
}
|
| 1074 |
+
};
|
| 1075 |
+
|
| 1076 |
+
const exportFormats: ExportFormat[] = [
|
| 1077 |
+
{
|
| 1078 |
+
id: 'json',
|
| 1079 |
+
label: 'Export as JSON',
|
| 1080 |
+
icon: 'i-ph:file-json',
|
| 1081 |
+
handler: exportDebugInfo,
|
| 1082 |
+
},
|
| 1083 |
+
{
|
| 1084 |
+
id: 'csv',
|
| 1085 |
+
label: 'Export as CSV',
|
| 1086 |
+
icon: 'i-ph:file-csv',
|
| 1087 |
+
handler: exportAsCSV,
|
| 1088 |
+
},
|
| 1089 |
+
{
|
| 1090 |
+
id: 'pdf',
|
| 1091 |
+
label: 'Export as PDF',
|
| 1092 |
+
icon: 'i-ph:file-pdf',
|
| 1093 |
+
handler: exportAsPDF,
|
| 1094 |
+
},
|
| 1095 |
+
{
|
| 1096 |
+
id: 'txt',
|
| 1097 |
+
label: 'Export as Text',
|
| 1098 |
+
icon: 'i-ph:file-text',
|
| 1099 |
+
handler: exportAsText,
|
| 1100 |
+
},
|
| 1101 |
+
];
|
| 1102 |
+
|
| 1103 |
+
// Add Ollama health check function
|
| 1104 |
+
const checkOllamaStatus = useCallback(async () => {
|
| 1105 |
+
try {
|
| 1106 |
+
const ollamaProvider = providers?.Ollama;
|
| 1107 |
+
const baseUrl = ollamaProvider?.settings?.baseUrl || 'http://127.0.0.1:11434';
|
| 1108 |
+
|
| 1109 |
+
// First check if service is running
|
| 1110 |
+
const versionResponse = await fetch(`${baseUrl}/api/version`);
|
| 1111 |
+
|
| 1112 |
+
if (!versionResponse.ok) {
|
| 1113 |
+
throw new Error('Service not running');
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
// Then fetch installed models
|
| 1117 |
+
const modelsResponse = await fetch(`${baseUrl}/api/tags`);
|
| 1118 |
+
|
| 1119 |
+
const modelsData = (await modelsResponse.json()) as {
|
| 1120 |
+
models: Array<{ name: string; size: string; quantization: string }>;
|
| 1121 |
+
};
|
| 1122 |
+
|
| 1123 |
+
setOllamaStatus({
|
| 1124 |
+
isRunning: true,
|
| 1125 |
+
lastChecked: new Date(),
|
| 1126 |
+
models: modelsData.models,
|
| 1127 |
+
});
|
| 1128 |
+
} catch {
|
| 1129 |
+
setOllamaStatus({
|
| 1130 |
+
isRunning: false,
|
| 1131 |
+
error: 'Connection failed',
|
| 1132 |
+
lastChecked: new Date(),
|
| 1133 |
+
models: undefined,
|
| 1134 |
+
});
|
| 1135 |
+
}
|
| 1136 |
+
}, [providers]);
|
| 1137 |
+
|
| 1138 |
+
// Monitor Ollama provider status and check periodically
|
| 1139 |
+
useEffect(() => {
|
| 1140 |
+
const ollamaProvider = providers?.Ollama;
|
| 1141 |
+
|
| 1142 |
+
if (ollamaProvider?.settings?.enabled) {
|
| 1143 |
+
// Check immediately when provider is enabled
|
| 1144 |
+
checkOllamaStatus();
|
| 1145 |
+
|
| 1146 |
+
// Set up periodic checks every 10 seconds
|
| 1147 |
+
const intervalId = setInterval(checkOllamaStatus, 10000);
|
| 1148 |
+
|
| 1149 |
+
return () => clearInterval(intervalId);
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
return undefined;
|
| 1153 |
+
}, [providers, checkOllamaStatus]);
|
| 1154 |
+
|
| 1155 |
+
// Replace the existing export button with this new component
|
| 1156 |
+
const ExportButton = () => {
|
| 1157 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 1158 |
+
|
| 1159 |
+
const handleOpenChange = useCallback((open: boolean) => {
|
| 1160 |
+
setIsOpen(open);
|
| 1161 |
+
}, []);
|
| 1162 |
+
|
| 1163 |
+
const handleFormatClick = useCallback((handler: () => void) => {
|
| 1164 |
+
handler();
|
| 1165 |
+
setIsOpen(false);
|
| 1166 |
+
}, []);
|
| 1167 |
+
|
| 1168 |
+
return (
|
| 1169 |
+
<DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
|
| 1170 |
+
<button
|
| 1171 |
+
onClick={() => setIsOpen(true)}
|
| 1172 |
+
className={classNames(
|
| 1173 |
+
'group flex items-center gap-2',
|
| 1174 |
+
'rounded-lg px-3 py-1.5',
|
| 1175 |
+
'text-sm text-gray-900 dark:text-white',
|
| 1176 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 1177 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 1178 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 1179 |
+
'transition-all duration-200',
|
| 1180 |
+
)}
|
| 1181 |
+
>
|
| 1182 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
| 1183 |
+
Export
|
| 1184 |
+
</button>
|
| 1185 |
+
|
| 1186 |
+
<Dialog showCloseButton>
|
| 1187 |
+
<div className="p-6">
|
| 1188 |
+
<DialogTitle className="flex items-center gap-2">
|
| 1189 |
+
<div className="i-ph:download w-5 h-5" />
|
| 1190 |
+
Export Debug Information
|
| 1191 |
+
</DialogTitle>
|
| 1192 |
+
|
| 1193 |
+
<div className="mt-4 flex flex-col gap-2">
|
| 1194 |
+
{exportFormats.map((format) => (
|
| 1195 |
+
<button
|
| 1196 |
+
key={format.id}
|
| 1197 |
+
onClick={() => handleFormatClick(format.handler)}
|
| 1198 |
+
className={classNames(
|
| 1199 |
+
'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
|
| 1200 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 1201 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 1202 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 1203 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 1204 |
+
'text-bolt-elements-textPrimary',
|
| 1205 |
+
)}
|
| 1206 |
+
>
|
| 1207 |
+
<div className={classNames(format.icon, 'w-5 h-5')} />
|
| 1208 |
+
<div>
|
| 1209 |
+
<div className="font-medium">{format.label}</div>
|
| 1210 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
| 1211 |
+
{format.id === 'json' && 'Export as a structured JSON file'}
|
| 1212 |
+
{format.id === 'csv' && 'Export as a CSV spreadsheet'}
|
| 1213 |
+
{format.id === 'pdf' && 'Export as a formatted PDF document'}
|
| 1214 |
+
{format.id === 'txt' && 'Export as a formatted text file'}
|
| 1215 |
+
</div>
|
| 1216 |
+
</div>
|
| 1217 |
+
</button>
|
| 1218 |
+
))}
|
| 1219 |
+
</div>
|
| 1220 |
+
</div>
|
| 1221 |
+
</Dialog>
|
| 1222 |
+
</DialogRoot>
|
| 1223 |
+
);
|
| 1224 |
+
};
|
| 1225 |
+
|
| 1226 |
+
// Add helper function to get Ollama status text and color
|
| 1227 |
+
const getOllamaStatus = () => {
|
| 1228 |
+
const ollamaProvider = providers?.Ollama;
|
| 1229 |
+
const isOllamaEnabled = ollamaProvider?.settings?.enabled;
|
| 1230 |
+
|
| 1231 |
+
if (!isOllamaEnabled) {
|
| 1232 |
+
return {
|
| 1233 |
+
status: 'Disabled',
|
| 1234 |
+
color: 'text-red-500',
|
| 1235 |
+
bgColor: 'bg-red-500',
|
| 1236 |
+
message: 'Ollama provider is disabled in settings',
|
| 1237 |
+
};
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
if (!ollamaStatus.isRunning) {
|
| 1241 |
+
return {
|
| 1242 |
+
status: 'Not Running',
|
| 1243 |
+
color: 'text-red-500',
|
| 1244 |
+
bgColor: 'bg-red-500',
|
| 1245 |
+
message: ollamaStatus.error || 'Ollama service is not running',
|
| 1246 |
+
};
|
| 1247 |
+
}
|
| 1248 |
+
|
| 1249 |
+
const modelCount = ollamaStatus.models?.length ?? 0;
|
| 1250 |
+
|
| 1251 |
+
return {
|
| 1252 |
+
status: 'Running',
|
| 1253 |
+
color: 'text-green-500',
|
| 1254 |
+
bgColor: 'bg-green-500',
|
| 1255 |
+
message: `Ollama service is running with ${modelCount} installed models (Provider: Enabled)`,
|
| 1256 |
+
};
|
| 1257 |
+
};
|
| 1258 |
+
|
| 1259 |
+
// Add type for status result
|
| 1260 |
+
type StatusResult = {
|
| 1261 |
+
status: string;
|
| 1262 |
+
color: string;
|
| 1263 |
+
bgColor: string;
|
| 1264 |
+
message: string;
|
| 1265 |
+
};
|
| 1266 |
+
|
| 1267 |
+
const status = getOllamaStatus() as StatusResult;
|
| 1268 |
+
|
| 1269 |
+
return (
|
| 1270 |
+
<div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
|
| 1271 |
+
{/* Quick Stats Banner */}
|
| 1272 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 1273 |
+
{/* Errors Card */}
|
| 1274 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
| 1275 |
+
<div className="flex items-center gap-2">
|
| 1276 |
+
<div className="i-ph:warning-octagon text-purple-500 w-4 h-4" />
|
| 1277 |
+
<div className="text-sm text-bolt-elements-textSecondary">Errors</div>
|
| 1278 |
+
</div>
|
| 1279 |
+
<div className="flex items-center gap-2 mt-2">
|
| 1280 |
+
<span
|
| 1281 |
+
className={classNames('text-2xl font-semibold', errorLogs.length > 0 ? 'text-red-500' : 'text-green-500')}
|
| 1282 |
+
>
|
| 1283 |
+
{errorLogs.length}
|
| 1284 |
+
</span>
|
| 1285 |
+
</div>
|
| 1286 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
| 1287 |
+
<div
|
| 1288 |
+
className={classNames(
|
| 1289 |
+
'w-3.5 h-3.5',
|
| 1290 |
+
errorLogs.length > 0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500',
|
| 1291 |
+
)}
|
| 1292 |
+
/>
|
| 1293 |
+
{errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'}
|
| 1294 |
+
</div>
|
| 1295 |
+
</div>
|
| 1296 |
+
|
| 1297 |
+
{/* Memory Usage Card */}
|
| 1298 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
| 1299 |
+
<div className="flex items-center gap-2">
|
| 1300 |
+
<div className="i-ph:cpu text-purple-500 w-4 h-4" />
|
| 1301 |
+
<div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
|
| 1302 |
+
</div>
|
| 1303 |
+
<div className="flex items-center gap-2 mt-2">
|
| 1304 |
+
<span
|
| 1305 |
+
className={classNames(
|
| 1306 |
+
'text-2xl font-semibold',
|
| 1307 |
+
(systemInfo?.memory?.percentage ?? 0) > 80
|
| 1308 |
+
? 'text-red-500'
|
| 1309 |
+
: (systemInfo?.memory?.percentage ?? 0) > 60
|
| 1310 |
+
? 'text-yellow-500'
|
| 1311 |
+
: 'text-green-500',
|
| 1312 |
+
)}
|
| 1313 |
+
>
|
| 1314 |
+
{systemInfo?.memory?.percentage ?? 0}%
|
| 1315 |
+
</span>
|
| 1316 |
+
</div>
|
| 1317 |
+
<Progress
|
| 1318 |
+
value={systemInfo?.memory?.percentage ?? 0}
|
| 1319 |
+
className={classNames(
|
| 1320 |
+
'mt-2',
|
| 1321 |
+
(systemInfo?.memory?.percentage ?? 0) > 80
|
| 1322 |
+
? '[&>div]:bg-red-500'
|
| 1323 |
+
: (systemInfo?.memory?.percentage ?? 0) > 60
|
| 1324 |
+
? '[&>div]:bg-yellow-500'
|
| 1325 |
+
: '[&>div]:bg-green-500',
|
| 1326 |
+
)}
|
| 1327 |
+
/>
|
| 1328 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
| 1329 |
+
<div className="i-ph:info w-3.5 h-3.5 text-purple-500" />
|
| 1330 |
+
Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'}
|
| 1331 |
+
</div>
|
| 1332 |
+
</div>
|
| 1333 |
+
|
| 1334 |
+
{/* Page Load Time Card */}
|
| 1335 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
| 1336 |
+
<div className="flex items-center gap-2">
|
| 1337 |
+
<div className="i-ph:timer text-purple-500 w-4 h-4" />
|
| 1338 |
+
<div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
|
| 1339 |
+
</div>
|
| 1340 |
+
<div className="flex items-center gap-2 mt-2">
|
| 1341 |
+
<span
|
| 1342 |
+
className={classNames(
|
| 1343 |
+
'text-2xl font-semibold',
|
| 1344 |
+
(systemInfo?.performance.timing.loadTime ?? 0) > 2000
|
| 1345 |
+
? 'text-red-500'
|
| 1346 |
+
: (systemInfo?.performance.timing.loadTime ?? 0) > 1000
|
| 1347 |
+
? 'text-yellow-500'
|
| 1348 |
+
: 'text-green-500',
|
| 1349 |
+
)}
|
| 1350 |
+
>
|
| 1351 |
+
{systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s
|
| 1352 |
+
</span>
|
| 1353 |
+
</div>
|
| 1354 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
| 1355 |
+
<div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
|
| 1356 |
+
DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
|
| 1357 |
+
</div>
|
| 1358 |
+
</div>
|
| 1359 |
+
|
| 1360 |
+
{/* Network Speed Card */}
|
| 1361 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
| 1362 |
+
<div className="flex items-center gap-2">
|
| 1363 |
+
<div className="i-ph:wifi-high text-purple-500 w-4 h-4" />
|
| 1364 |
+
<div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
|
| 1365 |
+
</div>
|
| 1366 |
+
<div className="flex items-center gap-2 mt-2">
|
| 1367 |
+
<span
|
| 1368 |
+
className={classNames(
|
| 1369 |
+
'text-2xl font-semibold',
|
| 1370 |
+
(systemInfo?.network.downlink ?? 0) < 5
|
| 1371 |
+
? 'text-red-500'
|
| 1372 |
+
: (systemInfo?.network.downlink ?? 0) < 10
|
| 1373 |
+
? 'text-yellow-500'
|
| 1374 |
+
: 'text-green-500',
|
| 1375 |
+
)}
|
| 1376 |
+
>
|
| 1377 |
+
{systemInfo?.network.downlink ?? '-'} Mbps
|
| 1378 |
+
</span>
|
| 1379 |
+
</div>
|
| 1380 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
| 1381 |
+
<div className="i-ph:activity w-3.5 h-3.5 text-purple-500" />
|
| 1382 |
+
RTT: {systemInfo?.network.rtt ?? '-'} ms
|
| 1383 |
+
</div>
|
| 1384 |
+
</div>
|
| 1385 |
+
|
| 1386 |
+
{/* Ollama Service Card - Now spans all 4 columns */}
|
| 1387 |
+
<div className="md:col-span-4 p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[260px] flex flex-col">
|
| 1388 |
+
<div className="flex items-center justify-between">
|
| 1389 |
+
<div className="flex items-center gap-3">
|
| 1390 |
+
<div className="i-ph:robot text-purple-500 w-5 h-5" />
|
| 1391 |
+
<div>
|
| 1392 |
+
<div className="text-base font-medium text-bolt-elements-textPrimary">Ollama Service</div>
|
| 1393 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">{status.message}</div>
|
| 1394 |
+
</div>
|
| 1395 |
+
</div>
|
| 1396 |
+
<div className="flex items-center gap-3">
|
| 1397 |
+
<div className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-bolt-elements-background-depth-3">
|
| 1398 |
+
<div
|
| 1399 |
+
className={classNames('w-2 h-2 rounded-full animate-pulse', status.bgColor, {
|
| 1400 |
+
'shadow-lg shadow-green-500/20': status.status === 'Running',
|
| 1401 |
+
'shadow-lg shadow-red-500/20': status.status === 'Not Running',
|
| 1402 |
+
})}
|
| 1403 |
+
/>
|
| 1404 |
+
<span className={classNames('text-xs font-medium flex items-center gap-1', status.color)}>
|
| 1405 |
+
{status.status}
|
| 1406 |
+
</span>
|
| 1407 |
+
</div>
|
| 1408 |
+
<div className="text-[10px] text-bolt-elements-textTertiary flex items-center gap-1.5">
|
| 1409 |
+
<div className="i-ph:clock w-3 h-3" />
|
| 1410 |
+
{ollamaStatus.lastChecked.toLocaleTimeString()}
|
| 1411 |
+
</div>
|
| 1412 |
+
</div>
|
| 1413 |
+
</div>
|
| 1414 |
+
|
| 1415 |
+
<div className="mt-6 flex-1 min-h-0 flex flex-col">
|
| 1416 |
+
{status.status === 'Running' && ollamaStatus.models && ollamaStatus.models.length > 0 ? (
|
| 1417 |
+
<>
|
| 1418 |
+
<div className="text-xs font-medium text-bolt-elements-textSecondary flex items-center justify-between mb-3">
|
| 1419 |
+
<div className="flex items-center gap-2">
|
| 1420 |
+
<div className="i-ph:cube-duotone w-4 h-4 text-purple-500" />
|
| 1421 |
+
<span>Installed Models</span>
|
| 1422 |
+
<Badge variant="secondary" className="ml-1">
|
| 1423 |
+
{ollamaStatus.models.length}
|
| 1424 |
+
</Badge>
|
| 1425 |
+
</div>
|
| 1426 |
+
</div>
|
| 1427 |
+
<div className="overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 dark:hover:scrollbar-thumb-gray-600">
|
| 1428 |
+
<div className="grid grid-cols-2 gap-3 pr-2">
|
| 1429 |
+
{ollamaStatus.models.map((model) => (
|
| 1430 |
+
<div
|
| 1431 |
+
key={model.name}
|
| 1432 |
+
className="text-sm bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 rounded-lg px-4 py-3 flex items-center justify-between transition-colors group"
|
| 1433 |
+
>
|
| 1434 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
| 1435 |
+
<div className="i-ph:cube w-4 h-4 text-purple-500/70 group-hover:text-purple-500 transition-colors" />
|
| 1436 |
+
<span className="font-mono truncate">{model.name}</span>
|
| 1437 |
+
</div>
|
| 1438 |
+
<Badge variant="outline" className="ml-2 text-xs font-mono">
|
| 1439 |
+
{Math.round(parseInt(model.size) / 1024 / 1024)}MB
|
| 1440 |
+
</Badge>
|
| 1441 |
+
</div>
|
| 1442 |
+
))}
|
| 1443 |
+
</div>
|
| 1444 |
+
</div>
|
| 1445 |
+
</>
|
| 1446 |
+
) : (
|
| 1447 |
+
<div className="flex-1 flex items-center justify-center">
|
| 1448 |
+
<div className="flex flex-col items-center gap-3 max-w-[280px] text-center">
|
| 1449 |
+
<div
|
| 1450 |
+
className={classNames('w-12 h-12', {
|
| 1451 |
+
'i-ph:warning-circle text-red-500/80':
|
| 1452 |
+
status.status === 'Not Running' || status.status === 'Disabled',
|
| 1453 |
+
'i-ph:cube-duotone text-purple-500/80': status.status === 'Running',
|
| 1454 |
+
})}
|
| 1455 |
+
/>
|
| 1456 |
+
<span className="text-sm text-bolt-elements-textSecondary">{status.message}</span>
|
| 1457 |
+
</div>
|
| 1458 |
+
</div>
|
| 1459 |
+
)}
|
| 1460 |
+
</div>
|
| 1461 |
+
</div>
|
| 1462 |
+
</div>
|
| 1463 |
+
|
| 1464 |
+
{/* Action Buttons */}
|
| 1465 |
+
<div className="flex flex-wrap gap-4">
|
| 1466 |
+
<button
|
| 1467 |
+
onClick={getSystemInfo}
|
| 1468 |
+
disabled={loading.systemInfo}
|
| 1469 |
+
className={classNames(
|
| 1470 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
| 1471 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 1472 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 1473 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 1474 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 1475 |
+
'text-bolt-elements-textPrimary',
|
| 1476 |
+
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
|
| 1477 |
+
)}
|
| 1478 |
+
>
|
| 1479 |
+
{loading.systemInfo ? (
|
| 1480 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 1481 |
+
) : (
|
| 1482 |
+
<div className="i-ph:gear w-4 h-4" />
|
| 1483 |
+
)}
|
| 1484 |
+
Update System Info
|
| 1485 |
+
</button>
|
| 1486 |
+
|
| 1487 |
+
<button
|
| 1488 |
+
onClick={handleLogPerformance}
|
| 1489 |
+
disabled={loading.performance}
|
| 1490 |
+
className={classNames(
|
| 1491 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
| 1492 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 1493 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 1494 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 1495 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 1496 |
+
'text-bolt-elements-textPrimary',
|
| 1497 |
+
{ 'opacity-50 cursor-not-allowed': loading.performance },
|
| 1498 |
+
)}
|
| 1499 |
+
>
|
| 1500 |
+
{loading.performance ? (
|
| 1501 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 1502 |
+
) : (
|
| 1503 |
+
<div className="i-ph:chart-bar w-4 h-4" />
|
| 1504 |
+
)}
|
| 1505 |
+
Log Performance
|
| 1506 |
+
</button>
|
| 1507 |
+
|
| 1508 |
+
<button
|
| 1509 |
+
onClick={checkErrors}
|
| 1510 |
+
disabled={loading.errors}
|
| 1511 |
+
className={classNames(
|
| 1512 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
| 1513 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 1514 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 1515 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 1516 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 1517 |
+
'text-bolt-elements-textPrimary',
|
| 1518 |
+
{ 'opacity-50 cursor-not-allowed': loading.errors },
|
| 1519 |
+
)}
|
| 1520 |
+
>
|
| 1521 |
+
{loading.errors ? (
|
| 1522 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 1523 |
+
) : (
|
| 1524 |
+
<div className="i-ph:warning w-4 h-4" />
|
| 1525 |
+
)}
|
| 1526 |
+
Check Errors
|
| 1527 |
+
</button>
|
| 1528 |
+
|
| 1529 |
+
<button
|
| 1530 |
+
onClick={getWebAppInfo}
|
| 1531 |
+
disabled={loading.webAppInfo}
|
| 1532 |
+
className={classNames(
|
| 1533 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
| 1534 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 1535 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 1536 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 1537 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 1538 |
+
'text-bolt-elements-textPrimary',
|
| 1539 |
+
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
|
| 1540 |
+
)}
|
| 1541 |
+
>
|
| 1542 |
+
{loading.webAppInfo ? (
|
| 1543 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 1544 |
+
) : (
|
| 1545 |
+
<div className="i-ph:info w-4 h-4" />
|
| 1546 |
+
)}
|
| 1547 |
+
Fetch WebApp Info
|
| 1548 |
+
</button>
|
| 1549 |
+
|
| 1550 |
+
<ExportButton />
|
| 1551 |
+
</div>
|
| 1552 |
+
|
| 1553 |
+
{/* System Information */}
|
| 1554 |
+
<Collapsible
|
| 1555 |
+
open={openSections.system}
|
| 1556 |
+
onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, system: open }))}
|
| 1557 |
+
className="w-full"
|
| 1558 |
+
>
|
| 1559 |
+
<CollapsibleTrigger className="w-full">
|
| 1560 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1561 |
+
<div className="flex items-center gap-3">
|
| 1562 |
+
<div className="i-ph:cpu text-purple-500 w-5 h-5" />
|
| 1563 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">System Information</h3>
|
| 1564 |
+
</div>
|
| 1565 |
+
<div
|
| 1566 |
+
className={classNames(
|
| 1567 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
| 1568 |
+
openSections.system ? 'rotate-180' : '',
|
| 1569 |
+
)}
|
| 1570 |
+
/>
|
| 1571 |
+
</div>
|
| 1572 |
+
</CollapsibleTrigger>
|
| 1573 |
+
|
| 1574 |
+
<CollapsibleContent>
|
| 1575 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1576 |
+
{systemInfo ? (
|
| 1577 |
+
<div className="grid grid-cols-2 gap-6">
|
| 1578 |
+
<div className="space-y-2">
|
| 1579 |
+
<div className="text-sm flex items-center gap-2">
|
| 1580 |
+
<div className="i-ph:desktop text-bolt-elements-textSecondary w-4 h-4" />
|
| 1581 |
+
<span className="text-bolt-elements-textSecondary">OS: </span>
|
| 1582 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.os}</span>
|
| 1583 |
+
</div>
|
| 1584 |
+
<div className="text-sm flex items-center gap-2">
|
| 1585 |
+
<div className="i-ph:device-mobile text-bolt-elements-textSecondary w-4 h-4" />
|
| 1586 |
+
<span className="text-bolt-elements-textSecondary">Platform: </span>
|
| 1587 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.platform}</span>
|
| 1588 |
+
</div>
|
| 1589 |
+
<div className="text-sm flex items-center gap-2">
|
| 1590 |
+
<div className="i-ph:microchip text-bolt-elements-textSecondary w-4 h-4" />
|
| 1591 |
+
<span className="text-bolt-elements-textSecondary">Architecture: </span>
|
| 1592 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.arch}</span>
|
| 1593 |
+
</div>
|
| 1594 |
+
<div className="text-sm flex items-center gap-2">
|
| 1595 |
+
<div className="i-ph:cpu text-bolt-elements-textSecondary w-4 h-4" />
|
| 1596 |
+
<span className="text-bolt-elements-textSecondary">CPU Cores: </span>
|
| 1597 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.cpus}</span>
|
| 1598 |
+
</div>
|
| 1599 |
+
<div className="text-sm flex items-center gap-2">
|
| 1600 |
+
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
| 1601 |
+
<span className="text-bolt-elements-textSecondary">Node Version: </span>
|
| 1602 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.node}</span>
|
| 1603 |
+
</div>
|
| 1604 |
+
<div className="text-sm flex items-center gap-2">
|
| 1605 |
+
<div className="i-ph:wifi-high text-bolt-elements-textSecondary w-4 h-4" />
|
| 1606 |
+
<span className="text-bolt-elements-textSecondary">Network Type: </span>
|
| 1607 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1608 |
+
{systemInfo.network.type} ({systemInfo.network.effectiveType})
|
| 1609 |
+
</span>
|
| 1610 |
+
</div>
|
| 1611 |
+
<div className="text-sm flex items-center gap-2">
|
| 1612 |
+
<div className="i-ph:gauge text-bolt-elements-textSecondary w-4 h-4" />
|
| 1613 |
+
<span className="text-bolt-elements-textSecondary">Network Speed: </span>
|
| 1614 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1615 |
+
{systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms)
|
| 1616 |
+
</span>
|
| 1617 |
+
</div>
|
| 1618 |
+
{systemInfo.battery && (
|
| 1619 |
+
<div className="text-sm flex items-center gap-2">
|
| 1620 |
+
<div className="i-ph:battery-charging text-bolt-elements-textSecondary w-4 h-4" />
|
| 1621 |
+
<span className="text-bolt-elements-textSecondary">Battery: </span>
|
| 1622 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1623 |
+
{systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''}
|
| 1624 |
+
</span>
|
| 1625 |
+
</div>
|
| 1626 |
+
)}
|
| 1627 |
+
<div className="text-sm flex items-center gap-2">
|
| 1628 |
+
<div className="i-ph:hard-drive text-bolt-elements-textSecondary w-4 h-4" />
|
| 1629 |
+
<span className="text-bolt-elements-textSecondary">Storage: </span>
|
| 1630 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1631 |
+
{(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '}
|
| 1632 |
+
{(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB
|
| 1633 |
+
</span>
|
| 1634 |
+
</div>
|
| 1635 |
+
</div>
|
| 1636 |
+
<div className="space-y-2">
|
| 1637 |
+
<div className="text-sm flex items-center gap-2">
|
| 1638 |
+
<div className="i-ph:database text-bolt-elements-textSecondary w-4 h-4" />
|
| 1639 |
+
<span className="text-bolt-elements-textSecondary">Memory Usage: </span>
|
| 1640 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1641 |
+
{systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%)
|
| 1642 |
+
</span>
|
| 1643 |
+
</div>
|
| 1644 |
+
<div className="text-sm flex items-center gap-2">
|
| 1645 |
+
<div className="i-ph:browser text-bolt-elements-textSecondary w-4 h-4" />
|
| 1646 |
+
<span className="text-bolt-elements-textSecondary">Browser: </span>
|
| 1647 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1648 |
+
{systemInfo.browser.name} {systemInfo.browser.version}
|
| 1649 |
+
</span>
|
| 1650 |
+
</div>
|
| 1651 |
+
<div className="text-sm flex items-center gap-2">
|
| 1652 |
+
<div className="i-ph:monitor text-bolt-elements-textSecondary w-4 h-4" />
|
| 1653 |
+
<span className="text-bolt-elements-textSecondary">Screen: </span>
|
| 1654 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1655 |
+
{systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x)
|
| 1656 |
+
</span>
|
| 1657 |
+
</div>
|
| 1658 |
+
<div className="text-sm flex items-center gap-2">
|
| 1659 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
|
| 1660 |
+
<span className="text-bolt-elements-textSecondary">Timezone: </span>
|
| 1661 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.time.timezone}</span>
|
| 1662 |
+
</div>
|
| 1663 |
+
<div className="text-sm flex items-center gap-2">
|
| 1664 |
+
<div className="i-ph:translate text-bolt-elements-textSecondary w-4 h-4" />
|
| 1665 |
+
<span className="text-bolt-elements-textSecondary">Language: </span>
|
| 1666 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.browser.language}</span>
|
| 1667 |
+
</div>
|
| 1668 |
+
<div className="text-sm flex items-center gap-2">
|
| 1669 |
+
<div className="i-ph:chart-pie text-bolt-elements-textSecondary w-4 h-4" />
|
| 1670 |
+
<span className="text-bolt-elements-textSecondary">JS Heap: </span>
|
| 1671 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1672 |
+
{(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
|
| 1673 |
+
{(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB (
|
| 1674 |
+
{systemInfo.performance.memory.usagePercentage.toFixed(1)}%)
|
| 1675 |
+
</span>
|
| 1676 |
+
</div>
|
| 1677 |
+
<div className="text-sm flex items-center gap-2">
|
| 1678 |
+
<div className="i-ph:timer text-bolt-elements-textSecondary w-4 h-4" />
|
| 1679 |
+
<span className="text-bolt-elements-textSecondary">Page Load: </span>
|
| 1680 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1681 |
+
{(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
|
| 1682 |
+
</span>
|
| 1683 |
+
</div>
|
| 1684 |
+
<div className="text-sm flex items-center gap-2">
|
| 1685 |
+
<div className="i-ph:code text-bolt-elements-textSecondary w-4 h-4" />
|
| 1686 |
+
<span className="text-bolt-elements-textSecondary">DOM Ready: </span>
|
| 1687 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1688 |
+
{(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
|
| 1689 |
+
</span>
|
| 1690 |
+
</div>
|
| 1691 |
+
</div>
|
| 1692 |
+
</div>
|
| 1693 |
+
) : (
|
| 1694 |
+
<div className="text-sm text-bolt-elements-textSecondary">Loading system information...</div>
|
| 1695 |
+
)}
|
| 1696 |
+
</div>
|
| 1697 |
+
</CollapsibleContent>
|
| 1698 |
+
</Collapsible>
|
| 1699 |
+
|
| 1700 |
+
{/* Performance Metrics */}
|
| 1701 |
+
<Collapsible
|
| 1702 |
+
open={openSections.performance}
|
| 1703 |
+
onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, performance: open }))}
|
| 1704 |
+
className="w-full"
|
| 1705 |
+
>
|
| 1706 |
+
<CollapsibleTrigger className="w-full">
|
| 1707 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1708 |
+
<div className="flex items-center gap-3">
|
| 1709 |
+
<div className="i-ph:chart-line text-purple-500 w-5 h-5" />
|
| 1710 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Performance Metrics</h3>
|
| 1711 |
+
</div>
|
| 1712 |
+
<div
|
| 1713 |
+
className={classNames(
|
| 1714 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
| 1715 |
+
openSections.performance ? 'rotate-180' : '',
|
| 1716 |
+
)}
|
| 1717 |
+
/>
|
| 1718 |
+
</div>
|
| 1719 |
+
</CollapsibleTrigger>
|
| 1720 |
+
|
| 1721 |
+
<CollapsibleContent>
|
| 1722 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1723 |
+
{systemInfo && (
|
| 1724 |
+
<div className="grid grid-cols-2 gap-4">
|
| 1725 |
+
<div className="space-y-2">
|
| 1726 |
+
<div className="text-sm">
|
| 1727 |
+
<span className="text-bolt-elements-textSecondary">Page Load Time: </span>
|
| 1728 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1729 |
+
{(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
|
| 1730 |
+
</span>
|
| 1731 |
+
</div>
|
| 1732 |
+
<div className="text-sm">
|
| 1733 |
+
<span className="text-bolt-elements-textSecondary">DOM Ready Time: </span>
|
| 1734 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1735 |
+
{(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
|
| 1736 |
+
</span>
|
| 1737 |
+
</div>
|
| 1738 |
+
<div className="text-sm">
|
| 1739 |
+
<span className="text-bolt-elements-textSecondary">Request Time: </span>
|
| 1740 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1741 |
+
{(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s
|
| 1742 |
+
</span>
|
| 1743 |
+
</div>
|
| 1744 |
+
<div className="text-sm">
|
| 1745 |
+
<span className="text-bolt-elements-textSecondary">Redirect Time: </span>
|
| 1746 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1747 |
+
{(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s
|
| 1748 |
+
</span>
|
| 1749 |
+
</div>
|
| 1750 |
+
</div>
|
| 1751 |
+
<div className="space-y-2">
|
| 1752 |
+
<div className="text-sm">
|
| 1753 |
+
<span className="text-bolt-elements-textSecondary">JS Heap Usage: </span>
|
| 1754 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1755 |
+
{(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
|
| 1756 |
+
{(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB
|
| 1757 |
+
</span>
|
| 1758 |
+
</div>
|
| 1759 |
+
<div className="text-sm">
|
| 1760 |
+
<span className="text-bolt-elements-textSecondary">Heap Utilization: </span>
|
| 1761 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1762 |
+
{systemInfo.performance.memory.usagePercentage.toFixed(1)}%
|
| 1763 |
+
</span>
|
| 1764 |
+
</div>
|
| 1765 |
+
<div className="text-sm">
|
| 1766 |
+
<span className="text-bolt-elements-textSecondary">Navigation Type: </span>
|
| 1767 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1768 |
+
{systemInfo.performance.navigation.type === 0
|
| 1769 |
+
? 'Navigate'
|
| 1770 |
+
: systemInfo.performance.navigation.type === 1
|
| 1771 |
+
? 'Reload'
|
| 1772 |
+
: systemInfo.performance.navigation.type === 2
|
| 1773 |
+
? 'Back/Forward'
|
| 1774 |
+
: 'Other'}
|
| 1775 |
+
</span>
|
| 1776 |
+
</div>
|
| 1777 |
+
<div className="text-sm">
|
| 1778 |
+
<span className="text-bolt-elements-textSecondary">Redirects: </span>
|
| 1779 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1780 |
+
{systemInfo.performance.navigation.redirectCount}
|
| 1781 |
+
</span>
|
| 1782 |
+
</div>
|
| 1783 |
+
</div>
|
| 1784 |
+
</div>
|
| 1785 |
+
)}
|
| 1786 |
+
</div>
|
| 1787 |
+
</CollapsibleContent>
|
| 1788 |
+
</Collapsible>
|
| 1789 |
+
|
| 1790 |
+
{/* WebApp Information */}
|
| 1791 |
+
<Collapsible
|
| 1792 |
+
open={openSections.webapp}
|
| 1793 |
+
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, webapp: open }))}
|
| 1794 |
+
className="w-full"
|
| 1795 |
+
>
|
| 1796 |
+
<CollapsibleTrigger className="w-full">
|
| 1797 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1798 |
+
<div className="flex items-center gap-3">
|
| 1799 |
+
<div className="i-ph:info text-blue-500 w-5 h-5" />
|
| 1800 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">WebApp Information</h3>
|
| 1801 |
+
{loading.webAppInfo && <span className="loading loading-spinner loading-sm" />}
|
| 1802 |
+
</div>
|
| 1803 |
+
<div
|
| 1804 |
+
className={classNames(
|
| 1805 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
| 1806 |
+
openSections.webapp ? 'rotate-180' : '',
|
| 1807 |
+
)}
|
| 1808 |
+
/>
|
| 1809 |
+
</div>
|
| 1810 |
+
</CollapsibleTrigger>
|
| 1811 |
+
|
| 1812 |
+
<CollapsibleContent>
|
| 1813 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1814 |
+
{loading.webAppInfo ? (
|
| 1815 |
+
<div className="flex items-center justify-center p-8">
|
| 1816 |
+
<span className="loading loading-spinner loading-lg" />
|
| 1817 |
+
</div>
|
| 1818 |
+
) : !webAppInfo ? (
|
| 1819 |
+
<div className="flex flex-col items-center justify-center p-8 text-bolt-elements-textSecondary">
|
| 1820 |
+
<div className="i-ph:warning-circle w-8 h-8 mb-2" />
|
| 1821 |
+
<p>Failed to load WebApp information</p>
|
| 1822 |
+
<button
|
| 1823 |
+
onClick={() => getWebAppInfo()}
|
| 1824 |
+
className="mt-4 px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
| 1825 |
+
>
|
| 1826 |
+
Retry
|
| 1827 |
+
</button>
|
| 1828 |
+
</div>
|
| 1829 |
+
) : (
|
| 1830 |
+
<div className="grid grid-cols-2 gap-6">
|
| 1831 |
+
<div>
|
| 1832 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Basic Information</h3>
|
| 1833 |
+
<div className="space-y-3">
|
| 1834 |
+
<div className="text-sm flex items-center gap-2">
|
| 1835 |
+
<div className="i-ph:app-window text-bolt-elements-textSecondary w-4 h-4" />
|
| 1836 |
+
<span className="text-bolt-elements-textSecondary">Name:</span>
|
| 1837 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.name}</span>
|
| 1838 |
+
</div>
|
| 1839 |
+
<div className="text-sm flex items-center gap-2">
|
| 1840 |
+
<div className="i-ph:tag text-bolt-elements-textSecondary w-4 h-4" />
|
| 1841 |
+
<span className="text-bolt-elements-textSecondary">Version:</span>
|
| 1842 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.version}</span>
|
| 1843 |
+
</div>
|
| 1844 |
+
<div className="text-sm flex items-center gap-2">
|
| 1845 |
+
<div className="i-ph:certificate text-bolt-elements-textSecondary w-4 h-4" />
|
| 1846 |
+
<span className="text-bolt-elements-textSecondary">License:</span>
|
| 1847 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.license}</span>
|
| 1848 |
+
</div>
|
| 1849 |
+
<div className="text-sm flex items-center gap-2">
|
| 1850 |
+
<div className="i-ph:cloud text-bolt-elements-textSecondary w-4 h-4" />
|
| 1851 |
+
<span className="text-bolt-elements-textSecondary">Environment:</span>
|
| 1852 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.environment}</span>
|
| 1853 |
+
</div>
|
| 1854 |
+
<div className="text-sm flex items-center gap-2">
|
| 1855 |
+
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
| 1856 |
+
<span className="text-bolt-elements-textSecondary">Node Version:</span>
|
| 1857 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.runtimeInfo.nodeVersion}</span>
|
| 1858 |
+
</div>
|
| 1859 |
+
</div>
|
| 1860 |
+
</div>
|
| 1861 |
+
|
| 1862 |
+
<div>
|
| 1863 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Git Information</h3>
|
| 1864 |
+
<div className="space-y-3">
|
| 1865 |
+
<div className="text-sm flex items-center gap-2">
|
| 1866 |
+
<div className="i-ph:git-branch text-bolt-elements-textSecondary w-4 h-4" />
|
| 1867 |
+
<span className="text-bolt-elements-textSecondary">Branch:</span>
|
| 1868 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.branch}</span>
|
| 1869 |
+
</div>
|
| 1870 |
+
<div className="text-sm flex items-center gap-2">
|
| 1871 |
+
<div className="i-ph:git-commit text-bolt-elements-textSecondary w-4 h-4" />
|
| 1872 |
+
<span className="text-bolt-elements-textSecondary">Commit:</span>
|
| 1873 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitHash}</span>
|
| 1874 |
+
</div>
|
| 1875 |
+
<div className="text-sm flex items-center gap-2">
|
| 1876 |
+
<div className="i-ph:user text-bolt-elements-textSecondary w-4 h-4" />
|
| 1877 |
+
<span className="text-bolt-elements-textSecondary">Author:</span>
|
| 1878 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.author}</span>
|
| 1879 |
+
</div>
|
| 1880 |
+
<div className="text-sm flex items-center gap-2">
|
| 1881 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
|
| 1882 |
+
<span className="text-bolt-elements-textSecondary">Commit Time:</span>
|
| 1883 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitTime}</span>
|
| 1884 |
+
</div>
|
| 1885 |
+
|
| 1886 |
+
{webAppInfo.gitInfo.github && (
|
| 1887 |
+
<>
|
| 1888 |
+
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
| 1889 |
+
<div className="text-sm flex items-center gap-2">
|
| 1890 |
+
<div className="i-ph:git-repository text-bolt-elements-textSecondary w-4 h-4" />
|
| 1891 |
+
<span className="text-bolt-elements-textSecondary">Repository:</span>
|
| 1892 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1893 |
+
{webAppInfo.gitInfo.github.currentRepo.fullName}
|
| 1894 |
+
{webAppInfo.gitInfo.isForked && ' (fork)'}
|
| 1895 |
+
</span>
|
| 1896 |
+
</div>
|
| 1897 |
+
|
| 1898 |
+
<div className="mt-2 flex items-center gap-4 text-sm">
|
| 1899 |
+
<div className="flex items-center gap-1">
|
| 1900 |
+
<div className="i-ph:star text-yellow-500 w-4 h-4" />
|
| 1901 |
+
<span className="text-bolt-elements-textSecondary">
|
| 1902 |
+
{webAppInfo.gitInfo.github.currentRepo.stars}
|
| 1903 |
+
</span>
|
| 1904 |
+
</div>
|
| 1905 |
+
<div className="flex items-center gap-1">
|
| 1906 |
+
<div className="i-ph:git-fork text-blue-500 w-4 h-4" />
|
| 1907 |
+
<span className="text-bolt-elements-textSecondary">
|
| 1908 |
+
{webAppInfo.gitInfo.github.currentRepo.forks}
|
| 1909 |
+
</span>
|
| 1910 |
+
</div>
|
| 1911 |
+
<div className="flex items-center gap-1">
|
| 1912 |
+
<div className="i-ph:warning-circle text-red-500 w-4 h-4" />
|
| 1913 |
+
<span className="text-bolt-elements-textSecondary">
|
| 1914 |
+
{webAppInfo.gitInfo.github.currentRepo.openIssues}
|
| 1915 |
+
</span>
|
| 1916 |
+
</div>
|
| 1917 |
+
</div>
|
| 1918 |
+
</div>
|
| 1919 |
+
|
| 1920 |
+
{webAppInfo.gitInfo.github.upstream && (
|
| 1921 |
+
<div className="mt-2">
|
| 1922 |
+
<div className="text-sm flex items-center gap-2">
|
| 1923 |
+
<div className="i-ph:git-fork text-bolt-elements-textSecondary w-4 h-4" />
|
| 1924 |
+
<span className="text-bolt-elements-textSecondary">Upstream:</span>
|
| 1925 |
+
<span className="text-bolt-elements-textPrimary">
|
| 1926 |
+
{webAppInfo.gitInfo.github.upstream.fullName}
|
| 1927 |
+
</span>
|
| 1928 |
+
</div>
|
| 1929 |
+
|
| 1930 |
+
<div className="mt-2 flex items-center gap-4 text-sm">
|
| 1931 |
+
<div className="flex items-center gap-1">
|
| 1932 |
+
<div className="i-ph:star text-yellow-500 w-4 h-4" />
|
| 1933 |
+
<span className="text-bolt-elements-textSecondary">
|
| 1934 |
+
{webAppInfo.gitInfo.github.upstream.stars}
|
| 1935 |
+
</span>
|
| 1936 |
+
</div>
|
| 1937 |
+
<div className="flex items-center gap-1">
|
| 1938 |
+
<div className="i-ph:git-fork text-blue-500 w-4 h-4" />
|
| 1939 |
+
<span className="text-bolt-elements-textSecondary">
|
| 1940 |
+
{webAppInfo.gitInfo.github.upstream.forks}
|
| 1941 |
+
</span>
|
| 1942 |
+
</div>
|
| 1943 |
+
</div>
|
| 1944 |
+
</div>
|
| 1945 |
+
)}
|
| 1946 |
+
</>
|
| 1947 |
+
)}
|
| 1948 |
+
</div>
|
| 1949 |
+
</div>
|
| 1950 |
+
</div>
|
| 1951 |
+
)}
|
| 1952 |
+
|
| 1953 |
+
{webAppInfo && (
|
| 1954 |
+
<div className="mt-6">
|
| 1955 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
|
| 1956 |
+
<div className="bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded-lg divide-y divide-[#E5E5E5] dark:divide-[#1A1A1A]">
|
| 1957 |
+
<DependencySection title="Production" deps={webAppInfo.dependencies.production} />
|
| 1958 |
+
<DependencySection title="Development" deps={webAppInfo.dependencies.development} />
|
| 1959 |
+
<DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />
|
| 1960 |
+
<DependencySection title="Optional" deps={webAppInfo.dependencies.optional} />
|
| 1961 |
+
</div>
|
| 1962 |
+
</div>
|
| 1963 |
+
)}
|
| 1964 |
+
</div>
|
| 1965 |
+
</CollapsibleContent>
|
| 1966 |
+
</Collapsible>
|
| 1967 |
+
|
| 1968 |
+
{/* Error Check */}
|
| 1969 |
+
<Collapsible
|
| 1970 |
+
open={openSections.errors}
|
| 1971 |
+
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, errors: open }))}
|
| 1972 |
+
className="w-full"
|
| 1973 |
+
>
|
| 1974 |
+
<CollapsibleTrigger className="w-full">
|
| 1975 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1976 |
+
<div className="flex items-center gap-3">
|
| 1977 |
+
<div className="i-ph:warning text-red-500 w-5 h-5" />
|
| 1978 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Error Check</h3>
|
| 1979 |
+
{errorLogs.length > 0 && (
|
| 1980 |
+
<Badge variant="destructive" className="ml-2">
|
| 1981 |
+
{errorLogs.length} Errors
|
| 1982 |
+
</Badge>
|
| 1983 |
+
)}
|
| 1984 |
+
</div>
|
| 1985 |
+
<div
|
| 1986 |
+
className={classNames(
|
| 1987 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
| 1988 |
+
openSections.errors ? 'rotate-180' : '',
|
| 1989 |
+
)}
|
| 1990 |
+
/>
|
| 1991 |
+
</div>
|
| 1992 |
+
</CollapsibleTrigger>
|
| 1993 |
+
|
| 1994 |
+
<CollapsibleContent>
|
| 1995 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
| 1996 |
+
<ScrollArea className="h-[300px]">
|
| 1997 |
+
<div className="space-y-4">
|
| 1998 |
+
<div className="text-sm text-bolt-elements-textSecondary">
|
| 1999 |
+
Checks for:
|
| 2000 |
+
<ul className="list-disc list-inside mt-2 space-y-1">
|
| 2001 |
+
<li>Unhandled JavaScript errors</li>
|
| 2002 |
+
<li>Unhandled Promise rejections</li>
|
| 2003 |
+
<li>Runtime exceptions</li>
|
| 2004 |
+
<li>Network errors</li>
|
| 2005 |
+
</ul>
|
| 2006 |
+
</div>
|
| 2007 |
+
<div className="text-sm">
|
| 2008 |
+
<span className="text-bolt-elements-textSecondary">Status: </span>
|
| 2009 |
+
<span className="text-bolt-elements-textPrimary">
|
| 2010 |
+
{loading.errors
|
| 2011 |
+
? 'Checking...'
|
| 2012 |
+
: errorLogs.length > 0
|
| 2013 |
+
? `${errorLogs.length} errors found`
|
| 2014 |
+
: 'No errors found'}
|
| 2015 |
+
</span>
|
| 2016 |
+
</div>
|
| 2017 |
+
{errorLogs.length > 0 && (
|
| 2018 |
+
<div className="mt-4">
|
| 2019 |
+
<div className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Recent Errors:</div>
|
| 2020 |
+
<div className="space-y-2">
|
| 2021 |
+
{errorLogs.map((error) => (
|
| 2022 |
+
<div key={error.id} className="text-sm text-red-500 dark:text-red-400 p-2 rounded bg-red-500/5">
|
| 2023 |
+
<div className="font-medium">{error.message}</div>
|
| 2024 |
+
{error.source && (
|
| 2025 |
+
<div className="text-xs mt-1 text-red-400">
|
| 2026 |
+
Source: {error.source}
|
| 2027 |
+
{error.details?.lineNumber && `:${error.details.lineNumber}`}
|
| 2028 |
+
</div>
|
| 2029 |
+
)}
|
| 2030 |
+
{error.stack && (
|
| 2031 |
+
<div className="text-xs mt-1 text-red-400 font-mono whitespace-pre-wrap">{error.stack}</div>
|
| 2032 |
+
)}
|
| 2033 |
+
</div>
|
| 2034 |
+
))}
|
| 2035 |
+
</div>
|
| 2036 |
+
</div>
|
| 2037 |
+
)}
|
| 2038 |
+
</div>
|
| 2039 |
+
</ScrollArea>
|
| 2040 |
+
</div>
|
| 2041 |
+
</CollapsibleContent>
|
| 2042 |
+
</Collapsible>
|
| 2043 |
+
</div>
|
| 2044 |
+
);
|
| 2045 |
+
}
|
app/components/@settings/tabs/event-logs/EventLogsTab.tsx
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { Switch } from '~/components/ui/Switch';
|
| 4 |
+
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
| 5 |
+
import { useStore } from '@nanostores/react';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
| 8 |
+
import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
| 9 |
+
import { jsPDF } from 'jspdf';
|
| 10 |
+
import { toast } from 'react-toastify';
|
| 11 |
+
|
| 12 |
+
interface SelectOption {
|
| 13 |
+
value: string;
|
| 14 |
+
label: string;
|
| 15 |
+
icon?: string;
|
| 16 |
+
color?: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const logLevelOptions: SelectOption[] = [
|
| 20 |
+
{
|
| 21 |
+
value: 'all',
|
| 22 |
+
label: 'All Types',
|
| 23 |
+
icon: 'i-ph:funnel',
|
| 24 |
+
color: '#9333ea',
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
value: 'provider',
|
| 28 |
+
label: 'LLM',
|
| 29 |
+
icon: 'i-ph:robot',
|
| 30 |
+
color: '#10b981',
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
value: 'api',
|
| 34 |
+
label: 'API',
|
| 35 |
+
icon: 'i-ph:cloud',
|
| 36 |
+
color: '#3b82f6',
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
value: 'error',
|
| 40 |
+
label: 'Errors',
|
| 41 |
+
icon: 'i-ph:warning-circle',
|
| 42 |
+
color: '#ef4444',
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
value: 'warning',
|
| 46 |
+
label: 'Warnings',
|
| 47 |
+
icon: 'i-ph:warning',
|
| 48 |
+
color: '#f59e0b',
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
value: 'info',
|
| 52 |
+
label: 'Info',
|
| 53 |
+
icon: 'i-ph:info',
|
| 54 |
+
color: '#3b82f6',
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
value: 'debug',
|
| 58 |
+
label: 'Debug',
|
| 59 |
+
icon: 'i-ph:bug',
|
| 60 |
+
color: '#6b7280',
|
| 61 |
+
},
|
| 62 |
+
];
|
| 63 |
+
|
| 64 |
+
interface LogEntryItemProps {
|
| 65 |
+
log: LogEntry;
|
| 66 |
+
isExpanded: boolean;
|
| 67 |
+
use24Hour: boolean;
|
| 68 |
+
showTimestamp: boolean;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
|
| 72 |
+
const [localExpanded, setLocalExpanded] = useState(forceExpanded);
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
setLocalExpanded(forceExpanded);
|
| 76 |
+
}, [forceExpanded]);
|
| 77 |
+
|
| 78 |
+
const timestamp = useMemo(() => {
|
| 79 |
+
const date = new Date(log.timestamp);
|
| 80 |
+
return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
|
| 81 |
+
}, [log.timestamp, use24Hour]);
|
| 82 |
+
|
| 83 |
+
const style = useMemo(() => {
|
| 84 |
+
if (log.category === 'provider') {
|
| 85 |
+
return {
|
| 86 |
+
icon: 'i-ph:robot',
|
| 87 |
+
color: 'text-emerald-500 dark:text-emerald-400',
|
| 88 |
+
bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
|
| 89 |
+
badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
|
| 90 |
+
};
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (log.category === 'api') {
|
| 94 |
+
return {
|
| 95 |
+
icon: 'i-ph:cloud',
|
| 96 |
+
color: 'text-blue-500 dark:text-blue-400',
|
| 97 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
| 98 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
switch (log.level) {
|
| 103 |
+
case 'error':
|
| 104 |
+
return {
|
| 105 |
+
icon: 'i-ph:warning-circle',
|
| 106 |
+
color: 'text-red-500 dark:text-red-400',
|
| 107 |
+
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
| 108 |
+
badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
|
| 109 |
+
};
|
| 110 |
+
case 'warning':
|
| 111 |
+
return {
|
| 112 |
+
icon: 'i-ph:warning',
|
| 113 |
+
color: 'text-yellow-500 dark:text-yellow-400',
|
| 114 |
+
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
| 115 |
+
badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
|
| 116 |
+
};
|
| 117 |
+
case 'debug':
|
| 118 |
+
return {
|
| 119 |
+
icon: 'i-ph:bug',
|
| 120 |
+
color: 'text-gray-500 dark:text-gray-400',
|
| 121 |
+
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
| 122 |
+
badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
|
| 123 |
+
};
|
| 124 |
+
default:
|
| 125 |
+
return {
|
| 126 |
+
icon: 'i-ph:info',
|
| 127 |
+
color: 'text-blue-500 dark:text-blue-400',
|
| 128 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
| 129 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
| 130 |
+
};
|
| 131 |
+
}
|
| 132 |
+
}, [log.level, log.category]);
|
| 133 |
+
|
| 134 |
+
const renderDetails = (details: any) => {
|
| 135 |
+
if (log.category === 'provider') {
|
| 136 |
+
return (
|
| 137 |
+
<div className="flex flex-col gap-2">
|
| 138 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
| 139 |
+
<span>Model: {details.model}</span>
|
| 140 |
+
<span>•</span>
|
| 141 |
+
<span>Tokens: {details.totalTokens}</span>
|
| 142 |
+
<span>•</span>
|
| 143 |
+
<span>Duration: {details.duration}ms</span>
|
| 144 |
+
</div>
|
| 145 |
+
{details.prompt && (
|
| 146 |
+
<div className="flex flex-col gap-1">
|
| 147 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Prompt:</div>
|
| 148 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
| 149 |
+
{details.prompt}
|
| 150 |
+
</pre>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
{details.response && (
|
| 154 |
+
<div className="flex flex-col gap-1">
|
| 155 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
| 156 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
| 157 |
+
{details.response}
|
| 158 |
+
</pre>
|
| 159 |
+
</div>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (log.category === 'api') {
|
| 166 |
+
return (
|
| 167 |
+
<div className="flex flex-col gap-2">
|
| 168 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
| 169 |
+
<span className={details.method === 'GET' ? 'text-green-500' : 'text-blue-500'}>{details.method}</span>
|
| 170 |
+
<span>•</span>
|
| 171 |
+
<span>Status: {details.statusCode}</span>
|
| 172 |
+
<span>•</span>
|
| 173 |
+
<span>Duration: {details.duration}ms</span>
|
| 174 |
+
</div>
|
| 175 |
+
<div className="text-xs text-gray-600 dark:text-gray-400 break-all">{details.url}</div>
|
| 176 |
+
{details.request && (
|
| 177 |
+
<div className="flex flex-col gap-1">
|
| 178 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Request:</div>
|
| 179 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
| 180 |
+
{JSON.stringify(details.request, null, 2)}
|
| 181 |
+
</pre>
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
{details.response && (
|
| 185 |
+
<div className="flex flex-col gap-1">
|
| 186 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
| 187 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
| 188 |
+
{JSON.stringify(details.response, null, 2)}
|
| 189 |
+
</pre>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
{details.error && (
|
| 193 |
+
<div className="flex flex-col gap-1">
|
| 194 |
+
<div className="text-xs font-medium text-red-500">Error:</div>
|
| 195 |
+
<pre className="text-xs text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-2 whitespace-pre-wrap">
|
| 196 |
+
{JSON.stringify(details.error, null, 2)}
|
| 197 |
+
</pre>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
return (
|
| 205 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded whitespace-pre-wrap">
|
| 206 |
+
{JSON.stringify(details, null, 2)}
|
| 207 |
+
</pre>
|
| 208 |
+
);
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
return (
|
| 212 |
+
<motion.div
|
| 213 |
+
initial={{ opacity: 0, y: 20 }}
|
| 214 |
+
animate={{ opacity: 1, y: 0 }}
|
| 215 |
+
className={classNames(
|
| 216 |
+
'flex flex-col gap-2',
|
| 217 |
+
'rounded-lg p-4',
|
| 218 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 219 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 220 |
+
style.bg,
|
| 221 |
+
'transition-all duration-200',
|
| 222 |
+
)}
|
| 223 |
+
>
|
| 224 |
+
<div className="flex items-start justify-between gap-4">
|
| 225 |
+
<div className="flex items-start gap-3">
|
| 226 |
+
<span className={classNames('text-lg', style.icon, style.color)} />
|
| 227 |
+
<div className="flex flex-col gap-1">
|
| 228 |
+
<div className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</div>
|
| 229 |
+
{log.details && (
|
| 230 |
+
<>
|
| 231 |
+
<button
|
| 232 |
+
onClick={() => setLocalExpanded(!localExpanded)}
|
| 233 |
+
className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
| 234 |
+
>
|
| 235 |
+
{localExpanded ? 'Hide' : 'Show'} Details
|
| 236 |
+
</button>
|
| 237 |
+
{localExpanded && renderDetails(log.details)}
|
| 238 |
+
</>
|
| 239 |
+
)}
|
| 240 |
+
<div className="flex items-center gap-2">
|
| 241 |
+
<div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', style.badge)}>
|
| 242 |
+
{log.level}
|
| 243 |
+
</div>
|
| 244 |
+
{log.category && (
|
| 245 |
+
<div className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
| 246 |
+
{log.category}
|
| 247 |
+
</div>
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
{showTimestamp && <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{timestamp}</time>}
|
| 253 |
+
</div>
|
| 254 |
+
</motion.div>
|
| 255 |
+
);
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
interface ExportFormat {
|
| 259 |
+
id: string;
|
| 260 |
+
label: string;
|
| 261 |
+
icon: string;
|
| 262 |
+
handler: () => void;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
export function EventLogsTab() {
|
| 266 |
+
const logs = useStore(logStore.logs);
|
| 267 |
+
const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
|
| 268 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 269 |
+
const [use24Hour, setUse24Hour] = useState(false);
|
| 270 |
+
const [autoExpand, setAutoExpand] = useState(false);
|
| 271 |
+
const [showTimestamps, setShowTimestamps] = useState(true);
|
| 272 |
+
const [showLevelFilter, setShowLevelFilter] = useState(false);
|
| 273 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
| 274 |
+
const levelFilterRef = useRef<HTMLDivElement>(null);
|
| 275 |
+
|
| 276 |
+
const filteredLogs = useMemo(() => {
|
| 277 |
+
const allLogs = Object.values(logs);
|
| 278 |
+
|
| 279 |
+
if (selectedLevel === 'all') {
|
| 280 |
+
return allLogs.filter((log) =>
|
| 281 |
+
searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
|
| 282 |
+
);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
return allLogs.filter((log) => {
|
| 286 |
+
const matchesType = log.category === selectedLevel || log.level === selectedLevel;
|
| 287 |
+
const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
|
| 288 |
+
|
| 289 |
+
return matchesType && matchesSearch;
|
| 290 |
+
});
|
| 291 |
+
}, [logs, selectedLevel, searchQuery]);
|
| 292 |
+
|
| 293 |
+
// Add performance tracking on mount
|
| 294 |
+
useEffect(() => {
|
| 295 |
+
const startTime = performance.now();
|
| 296 |
+
|
| 297 |
+
logStore.logInfo('Event Logs tab mounted', {
|
| 298 |
+
type: 'component_mount',
|
| 299 |
+
message: 'Event Logs tab component mounted',
|
| 300 |
+
component: 'EventLogsTab',
|
| 301 |
+
});
|
| 302 |
+
|
| 303 |
+
return () => {
|
| 304 |
+
const duration = performance.now() - startTime;
|
| 305 |
+
logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
|
| 306 |
+
};
|
| 307 |
+
}, []);
|
| 308 |
+
|
| 309 |
+
// Log filter changes
|
| 310 |
+
const handleLevelFilterChange = useCallback(
|
| 311 |
+
(newLevel: string) => {
|
| 312 |
+
logStore.logInfo('Log level filter changed', {
|
| 313 |
+
type: 'filter_change',
|
| 314 |
+
message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
|
| 315 |
+
component: 'EventLogsTab',
|
| 316 |
+
previousLevel: selectedLevel,
|
| 317 |
+
newLevel,
|
| 318 |
+
});
|
| 319 |
+
setSelectedLevel(newLevel as string);
|
| 320 |
+
setShowLevelFilter(false);
|
| 321 |
+
},
|
| 322 |
+
[selectedLevel],
|
| 323 |
+
);
|
| 324 |
+
|
| 325 |
+
// Log search changes with debounce
|
| 326 |
+
useEffect(() => {
|
| 327 |
+
const timeoutId = setTimeout(() => {
|
| 328 |
+
if (searchQuery) {
|
| 329 |
+
logStore.logInfo('Log search performed', {
|
| 330 |
+
type: 'search',
|
| 331 |
+
message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
|
| 332 |
+
component: 'EventLogsTab',
|
| 333 |
+
query: searchQuery,
|
| 334 |
+
resultsCount: filteredLogs.length,
|
| 335 |
+
});
|
| 336 |
+
}
|
| 337 |
+
}, 1000);
|
| 338 |
+
|
| 339 |
+
return () => clearTimeout(timeoutId);
|
| 340 |
+
}, [searchQuery, filteredLogs.length]);
|
| 341 |
+
|
| 342 |
+
// Enhanced refresh handler
|
| 343 |
+
const handleRefresh = useCallback(async () => {
|
| 344 |
+
const startTime = performance.now();
|
| 345 |
+
setIsRefreshing(true);
|
| 346 |
+
|
| 347 |
+
try {
|
| 348 |
+
await logStore.refreshLogs();
|
| 349 |
+
|
| 350 |
+
const duration = performance.now() - startTime;
|
| 351 |
+
|
| 352 |
+
logStore.logSuccess('Logs refreshed successfully', {
|
| 353 |
+
type: 'refresh',
|
| 354 |
+
message: `Successfully refreshed ${Object.keys(logs).length} logs`,
|
| 355 |
+
component: 'EventLogsTab',
|
| 356 |
+
duration,
|
| 357 |
+
logsCount: Object.keys(logs).length,
|
| 358 |
+
});
|
| 359 |
+
} catch (error) {
|
| 360 |
+
logStore.logError('Failed to refresh logs', error, {
|
| 361 |
+
type: 'refresh_error',
|
| 362 |
+
message: 'Failed to refresh logs',
|
| 363 |
+
component: 'EventLogsTab',
|
| 364 |
+
});
|
| 365 |
+
} finally {
|
| 366 |
+
setTimeout(() => setIsRefreshing(false), 500);
|
| 367 |
+
}
|
| 368 |
+
}, [logs]);
|
| 369 |
+
|
| 370 |
+
// Log preference changes
|
| 371 |
+
const handlePreferenceChange = useCallback((type: string, value: boolean) => {
|
| 372 |
+
logStore.logInfo('Log preference changed', {
|
| 373 |
+
type: 'preference_change',
|
| 374 |
+
message: `Log preference "${type}" changed to ${value}`,
|
| 375 |
+
component: 'EventLogsTab',
|
| 376 |
+
preference: type,
|
| 377 |
+
value,
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
switch (type) {
|
| 381 |
+
case 'timestamps':
|
| 382 |
+
setShowTimestamps(value);
|
| 383 |
+
break;
|
| 384 |
+
case '24hour':
|
| 385 |
+
setUse24Hour(value);
|
| 386 |
+
break;
|
| 387 |
+
case 'autoExpand':
|
| 388 |
+
setAutoExpand(value);
|
| 389 |
+
break;
|
| 390 |
+
}
|
| 391 |
+
}, []);
|
| 392 |
+
|
| 393 |
+
// Close filters when clicking outside
|
| 394 |
+
useEffect(() => {
|
| 395 |
+
const handleClickOutside = (event: MouseEvent) => {
|
| 396 |
+
if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
|
| 397 |
+
setShowLevelFilter(false);
|
| 398 |
+
}
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 402 |
+
|
| 403 |
+
return () => {
|
| 404 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
| 405 |
+
};
|
| 406 |
+
}, []);
|
| 407 |
+
|
| 408 |
+
const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
|
| 409 |
+
|
| 410 |
+
// Export functions
|
| 411 |
+
const exportAsJSON = () => {
|
| 412 |
+
try {
|
| 413 |
+
const exportData = {
|
| 414 |
+
timestamp: new Date().toISOString(),
|
| 415 |
+
logs: filteredLogs,
|
| 416 |
+
filters: {
|
| 417 |
+
level: selectedLevel,
|
| 418 |
+
searchQuery,
|
| 419 |
+
},
|
| 420 |
+
preferences: {
|
| 421 |
+
use24Hour,
|
| 422 |
+
showTimestamps,
|
| 423 |
+
autoExpand,
|
| 424 |
+
},
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
| 428 |
+
const url = window.URL.createObjectURL(blob);
|
| 429 |
+
const a = document.createElement('a');
|
| 430 |
+
a.href = url;
|
| 431 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.json`;
|
| 432 |
+
document.body.appendChild(a);
|
| 433 |
+
a.click();
|
| 434 |
+
window.URL.revokeObjectURL(url);
|
| 435 |
+
document.body.removeChild(a);
|
| 436 |
+
toast.success('Event logs exported successfully as JSON');
|
| 437 |
+
} catch (error) {
|
| 438 |
+
console.error('Failed to export JSON:', error);
|
| 439 |
+
toast.error('Failed to export event logs as JSON');
|
| 440 |
+
}
|
| 441 |
+
};
|
| 442 |
+
|
| 443 |
+
const exportAsCSV = () => {
|
| 444 |
+
try {
|
| 445 |
+
// Convert logs to CSV format
|
| 446 |
+
const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details'];
|
| 447 |
+
const csvData = [
|
| 448 |
+
headers,
|
| 449 |
+
...filteredLogs.map((log) => [
|
| 450 |
+
new Date(log.timestamp).toISOString(),
|
| 451 |
+
log.level,
|
| 452 |
+
log.category || '',
|
| 453 |
+
log.message,
|
| 454 |
+
log.details ? JSON.stringify(log.details) : '',
|
| 455 |
+
]),
|
| 456 |
+
];
|
| 457 |
+
|
| 458 |
+
const csvContent = csvData
|
| 459 |
+
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
| 460 |
+
.join('\n');
|
| 461 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
| 462 |
+
const url = window.URL.createObjectURL(blob);
|
| 463 |
+
const a = document.createElement('a');
|
| 464 |
+
a.href = url;
|
| 465 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.csv`;
|
| 466 |
+
document.body.appendChild(a);
|
| 467 |
+
a.click();
|
| 468 |
+
window.URL.revokeObjectURL(url);
|
| 469 |
+
document.body.removeChild(a);
|
| 470 |
+
toast.success('Event logs exported successfully as CSV');
|
| 471 |
+
} catch (error) {
|
| 472 |
+
console.error('Failed to export CSV:', error);
|
| 473 |
+
toast.error('Failed to export event logs as CSV');
|
| 474 |
+
}
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
const exportAsPDF = () => {
|
| 478 |
+
try {
|
| 479 |
+
// Create new PDF document
|
| 480 |
+
const doc = new jsPDF();
|
| 481 |
+
const lineHeight = 7;
|
| 482 |
+
let yPos = 20;
|
| 483 |
+
const margin = 20;
|
| 484 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
| 485 |
+
const maxLineWidth = pageWidth - 2 * margin;
|
| 486 |
+
|
| 487 |
+
// Helper function to add section header
|
| 488 |
+
const addSectionHeader = (title: string) => {
|
| 489 |
+
// Check if we need a new page
|
| 490 |
+
if (yPos > doc.internal.pageSize.getHeight() - 30) {
|
| 491 |
+
doc.addPage();
|
| 492 |
+
yPos = margin;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
doc.setFillColor('#F3F4F6');
|
| 496 |
+
doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
|
| 497 |
+
doc.setFont('helvetica', 'bold');
|
| 498 |
+
doc.setTextColor('#111827');
|
| 499 |
+
doc.setFontSize(12);
|
| 500 |
+
doc.text(title.toUpperCase(), margin, yPos);
|
| 501 |
+
yPos += lineHeight * 2;
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
+
// Add title and header
|
| 505 |
+
doc.setFillColor('#6366F1');
|
| 506 |
+
doc.rect(0, 0, pageWidth, 50, 'F');
|
| 507 |
+
doc.setTextColor('#FFFFFF');
|
| 508 |
+
doc.setFontSize(24);
|
| 509 |
+
doc.setFont('helvetica', 'bold');
|
| 510 |
+
doc.text('Event Logs Report', margin, 35);
|
| 511 |
+
|
| 512 |
+
// Add subtitle with bolt.diy
|
| 513 |
+
doc.setFontSize(12);
|
| 514 |
+
doc.setFont('helvetica', 'normal');
|
| 515 |
+
doc.text('bolt.diy - AI Development Platform', margin, 45);
|
| 516 |
+
yPos = 70;
|
| 517 |
+
|
| 518 |
+
// Add report summary section
|
| 519 |
+
addSectionHeader('Report Summary');
|
| 520 |
+
|
| 521 |
+
doc.setFontSize(10);
|
| 522 |
+
doc.setFont('helvetica', 'normal');
|
| 523 |
+
doc.setTextColor('#374151');
|
| 524 |
+
|
| 525 |
+
const summaryItems = [
|
| 526 |
+
{ label: 'Generated', value: new Date().toLocaleString() },
|
| 527 |
+
{ label: 'Total Logs', value: filteredLogs.length.toString() },
|
| 528 |
+
{ label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel },
|
| 529 |
+
{ label: 'Search Query', value: searchQuery || 'None' },
|
| 530 |
+
{ label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' },
|
| 531 |
+
];
|
| 532 |
+
|
| 533 |
+
summaryItems.forEach((item) => {
|
| 534 |
+
doc.setFont('helvetica', 'bold');
|
| 535 |
+
doc.text(`${item.label}:`, margin, yPos);
|
| 536 |
+
doc.setFont('helvetica', 'normal');
|
| 537 |
+
doc.text(item.value, margin + 60, yPos);
|
| 538 |
+
yPos += lineHeight;
|
| 539 |
+
});
|
| 540 |
+
|
| 541 |
+
yPos += lineHeight * 2;
|
| 542 |
+
|
| 543 |
+
// Add statistics section
|
| 544 |
+
addSectionHeader('Log Statistics');
|
| 545 |
+
|
| 546 |
+
// Calculate statistics
|
| 547 |
+
const stats = {
|
| 548 |
+
error: filteredLogs.filter((log) => log.level === 'error').length,
|
| 549 |
+
warning: filteredLogs.filter((log) => log.level === 'warning').length,
|
| 550 |
+
info: filteredLogs.filter((log) => log.level === 'info').length,
|
| 551 |
+
debug: filteredLogs.filter((log) => log.level === 'debug').length,
|
| 552 |
+
provider: filteredLogs.filter((log) => log.category === 'provider').length,
|
| 553 |
+
api: filteredLogs.filter((log) => log.category === 'api').length,
|
| 554 |
+
};
|
| 555 |
+
|
| 556 |
+
// Create two columns for statistics
|
| 557 |
+
const leftStats = [
|
| 558 |
+
{ label: 'Error Logs', value: stats.error, color: '#DC2626' },
|
| 559 |
+
{ label: 'Warning Logs', value: stats.warning, color: '#F59E0B' },
|
| 560 |
+
{ label: 'Info Logs', value: stats.info, color: '#3B82F6' },
|
| 561 |
+
];
|
| 562 |
+
|
| 563 |
+
const rightStats = [
|
| 564 |
+
{ label: 'Debug Logs', value: stats.debug, color: '#6B7280' },
|
| 565 |
+
{ label: 'LLM Logs', value: stats.provider, color: '#10B981' },
|
| 566 |
+
{ label: 'API Logs', value: stats.api, color: '#3B82F6' },
|
| 567 |
+
];
|
| 568 |
+
|
| 569 |
+
const colWidth = (pageWidth - 2 * margin) / 2;
|
| 570 |
+
|
| 571 |
+
// Draw statistics in two columns
|
| 572 |
+
leftStats.forEach((stat, index) => {
|
| 573 |
+
doc.setTextColor(stat.color);
|
| 574 |
+
doc.setFont('helvetica', 'bold');
|
| 575 |
+
doc.text(stat.value.toString(), margin, yPos);
|
| 576 |
+
doc.setTextColor('#374151');
|
| 577 |
+
doc.setFont('helvetica', 'normal');
|
| 578 |
+
doc.text(stat.label, margin + 20, yPos);
|
| 579 |
+
|
| 580 |
+
if (rightStats[index]) {
|
| 581 |
+
doc.setTextColor(rightStats[index].color);
|
| 582 |
+
doc.setFont('helvetica', 'bold');
|
| 583 |
+
doc.text(rightStats[index].value.toString(), margin + colWidth, yPos);
|
| 584 |
+
doc.setTextColor('#374151');
|
| 585 |
+
doc.setFont('helvetica', 'normal');
|
| 586 |
+
doc.text(rightStats[index].label, margin + colWidth + 20, yPos);
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
yPos += lineHeight;
|
| 590 |
+
});
|
| 591 |
+
|
| 592 |
+
yPos += lineHeight * 2;
|
| 593 |
+
|
| 594 |
+
// Add logs section
|
| 595 |
+
addSectionHeader('Event Logs');
|
| 596 |
+
|
| 597 |
+
// Helper function to add a log entry with improved formatting
|
| 598 |
+
const addLogEntry = (log: LogEntry) => {
|
| 599 |
+
const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height
|
| 600 |
+
|
| 601 |
+
// Check if we need a new page
|
| 602 |
+
if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) {
|
| 603 |
+
doc.addPage();
|
| 604 |
+
yPos = margin;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
// Add timestamp and level
|
| 608 |
+
const timestamp = new Date(log.timestamp).toLocaleString(undefined, {
|
| 609 |
+
year: 'numeric',
|
| 610 |
+
month: '2-digit',
|
| 611 |
+
day: '2-digit',
|
| 612 |
+
hour: '2-digit',
|
| 613 |
+
minute: '2-digit',
|
| 614 |
+
second: '2-digit',
|
| 615 |
+
hour12: !use24Hour,
|
| 616 |
+
});
|
| 617 |
+
|
| 618 |
+
// Draw log level badge background
|
| 619 |
+
const levelColors: Record<string, string> = {
|
| 620 |
+
error: '#FEE2E2',
|
| 621 |
+
warning: '#FEF3C7',
|
| 622 |
+
info: '#DBEAFE',
|
| 623 |
+
debug: '#F3F4F6',
|
| 624 |
+
};
|
| 625 |
+
|
| 626 |
+
const textColors: Record<string, string> = {
|
| 627 |
+
error: '#DC2626',
|
| 628 |
+
warning: '#F59E0B',
|
| 629 |
+
info: '#3B82F6',
|
| 630 |
+
debug: '#6B7280',
|
| 631 |
+
};
|
| 632 |
+
|
| 633 |
+
const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10;
|
| 634 |
+
doc.setFillColor(levelColors[log.level] || '#F3F4F6');
|
| 635 |
+
doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F');
|
| 636 |
+
|
| 637 |
+
// Add log level text
|
| 638 |
+
doc.setTextColor(textColors[log.level] || '#6B7280');
|
| 639 |
+
doc.setFont('helvetica', 'bold');
|
| 640 |
+
doc.setFontSize(8);
|
| 641 |
+
doc.text(log.level.toUpperCase(), margin + 5, yPos);
|
| 642 |
+
|
| 643 |
+
// Add timestamp
|
| 644 |
+
doc.setTextColor('#6B7280');
|
| 645 |
+
doc.setFont('helvetica', 'normal');
|
| 646 |
+
doc.setFontSize(9);
|
| 647 |
+
doc.text(timestamp, margin + levelWidth + 10, yPos);
|
| 648 |
+
|
| 649 |
+
// Add category if present
|
| 650 |
+
if (log.category) {
|
| 651 |
+
const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20;
|
| 652 |
+
doc.setFillColor('#F3F4F6');
|
| 653 |
+
|
| 654 |
+
const categoryWidth = doc.getTextWidth(log.category) + 10;
|
| 655 |
+
doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F');
|
| 656 |
+
doc.setTextColor('#6B7280');
|
| 657 |
+
doc.text(log.category, categoryX + 5, yPos);
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
yPos += lineHeight * 1.5;
|
| 661 |
+
|
| 662 |
+
// Add message
|
| 663 |
+
doc.setTextColor('#111827');
|
| 664 |
+
doc.setFontSize(10);
|
| 665 |
+
|
| 666 |
+
const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10);
|
| 667 |
+
doc.text(messageLines, margin + 5, yPos);
|
| 668 |
+
yPos += messageLines.length * lineHeight;
|
| 669 |
+
|
| 670 |
+
// Add details if present
|
| 671 |
+
if (log.details) {
|
| 672 |
+
doc.setTextColor('#6B7280');
|
| 673 |
+
doc.setFontSize(8);
|
| 674 |
+
|
| 675 |
+
const detailsStr = JSON.stringify(log.details, null, 2);
|
| 676 |
+
const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15);
|
| 677 |
+
|
| 678 |
+
// Add details background
|
| 679 |
+
doc.setFillColor('#F9FAFB');
|
| 680 |
+
doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F');
|
| 681 |
+
|
| 682 |
+
doc.text(detailsLines, margin + 10, yPos + 4);
|
| 683 |
+
yPos += detailsLines.length * lineHeight + 10;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
// Add separator line
|
| 687 |
+
doc.setDrawColor('#E5E7EB');
|
| 688 |
+
doc.setLineWidth(0.1);
|
| 689 |
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
| 690 |
+
yPos += lineHeight * 1.5;
|
| 691 |
+
};
|
| 692 |
+
|
| 693 |
+
// Add all logs
|
| 694 |
+
filteredLogs.forEach((log) => {
|
| 695 |
+
addLogEntry(log);
|
| 696 |
+
});
|
| 697 |
+
|
| 698 |
+
// Add footer to all pages
|
| 699 |
+
const totalPages = doc.internal.pages.length - 1;
|
| 700 |
+
|
| 701 |
+
for (let i = 1; i <= totalPages; i++) {
|
| 702 |
+
doc.setPage(i);
|
| 703 |
+
doc.setFontSize(8);
|
| 704 |
+
doc.setTextColor('#9CA3AF');
|
| 705 |
+
|
| 706 |
+
// Add page numbers
|
| 707 |
+
doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
|
| 708 |
+
align: 'center',
|
| 709 |
+
});
|
| 710 |
+
|
| 711 |
+
// Add footer text
|
| 712 |
+
doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10);
|
| 713 |
+
|
| 714 |
+
const dateStr = new Date().toLocaleDateString();
|
| 715 |
+
doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' });
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
// Save the PDF
|
| 719 |
+
doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`);
|
| 720 |
+
toast.success('Event logs exported successfully as PDF');
|
| 721 |
+
} catch (error) {
|
| 722 |
+
console.error('Failed to export PDF:', error);
|
| 723 |
+
toast.error('Failed to export event logs as PDF');
|
| 724 |
+
}
|
| 725 |
+
};
|
| 726 |
+
|
| 727 |
+
const exportAsText = () => {
|
| 728 |
+
try {
|
| 729 |
+
const textContent = filteredLogs
|
| 730 |
+
.map((log) => {
|
| 731 |
+
const timestamp = new Date(log.timestamp).toLocaleString();
|
| 732 |
+
let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`;
|
| 733 |
+
|
| 734 |
+
if (log.category) {
|
| 735 |
+
content += `Category: ${log.category}\n`;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
if (log.details) {
|
| 739 |
+
content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
return content + '-'.repeat(80) + '\n';
|
| 743 |
+
})
|
| 744 |
+
.join('\n');
|
| 745 |
+
|
| 746 |
+
const blob = new Blob([textContent], { type: 'text/plain' });
|
| 747 |
+
const url = window.URL.createObjectURL(blob);
|
| 748 |
+
const a = document.createElement('a');
|
| 749 |
+
a.href = url;
|
| 750 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.txt`;
|
| 751 |
+
document.body.appendChild(a);
|
| 752 |
+
a.click();
|
| 753 |
+
window.URL.revokeObjectURL(url);
|
| 754 |
+
document.body.removeChild(a);
|
| 755 |
+
toast.success('Event logs exported successfully as text file');
|
| 756 |
+
} catch (error) {
|
| 757 |
+
console.error('Failed to export text file:', error);
|
| 758 |
+
toast.error('Failed to export event logs as text file');
|
| 759 |
+
}
|
| 760 |
+
};
|
| 761 |
+
|
| 762 |
+
const exportFormats: ExportFormat[] = [
|
| 763 |
+
{
|
| 764 |
+
id: 'json',
|
| 765 |
+
label: 'Export as JSON',
|
| 766 |
+
icon: 'i-ph:file-json',
|
| 767 |
+
handler: exportAsJSON,
|
| 768 |
+
},
|
| 769 |
+
{
|
| 770 |
+
id: 'csv',
|
| 771 |
+
label: 'Export as CSV',
|
| 772 |
+
icon: 'i-ph:file-csv',
|
| 773 |
+
handler: exportAsCSV,
|
| 774 |
+
},
|
| 775 |
+
{
|
| 776 |
+
id: 'pdf',
|
| 777 |
+
label: 'Export as PDF',
|
| 778 |
+
icon: 'i-ph:file-pdf',
|
| 779 |
+
handler: exportAsPDF,
|
| 780 |
+
},
|
| 781 |
+
{
|
| 782 |
+
id: 'txt',
|
| 783 |
+
label: 'Export as Text',
|
| 784 |
+
icon: 'i-ph:file-text',
|
| 785 |
+
handler: exportAsText,
|
| 786 |
+
},
|
| 787 |
+
];
|
| 788 |
+
|
| 789 |
+
const ExportButton = () => {
|
| 790 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 791 |
+
|
| 792 |
+
const handleOpenChange = useCallback((open: boolean) => {
|
| 793 |
+
setIsOpen(open);
|
| 794 |
+
}, []);
|
| 795 |
+
|
| 796 |
+
const handleFormatClick = useCallback((handler: () => void) => {
|
| 797 |
+
handler();
|
| 798 |
+
setIsOpen(false);
|
| 799 |
+
}, []);
|
| 800 |
+
|
| 801 |
+
return (
|
| 802 |
+
<DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
|
| 803 |
+
<button
|
| 804 |
+
onClick={() => setIsOpen(true)}
|
| 805 |
+
className={classNames(
|
| 806 |
+
'group flex items-center gap-2',
|
| 807 |
+
'rounded-lg px-3 py-1.5',
|
| 808 |
+
'text-sm text-gray-900 dark:text-white',
|
| 809 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 810 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 811 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 812 |
+
'transition-all duration-200',
|
| 813 |
+
)}
|
| 814 |
+
>
|
| 815 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
| 816 |
+
Export
|
| 817 |
+
</button>
|
| 818 |
+
|
| 819 |
+
<Dialog showCloseButton>
|
| 820 |
+
<div className="p-6">
|
| 821 |
+
<DialogTitle className="flex items-center gap-2">
|
| 822 |
+
<div className="i-ph:download w-5 h-5" />
|
| 823 |
+
Export Event Logs
|
| 824 |
+
</DialogTitle>
|
| 825 |
+
|
| 826 |
+
<div className="mt-4 flex flex-col gap-2">
|
| 827 |
+
{exportFormats.map((format) => (
|
| 828 |
+
<button
|
| 829 |
+
key={format.id}
|
| 830 |
+
onClick={() => handleFormatClick(format.handler)}
|
| 831 |
+
className={classNames(
|
| 832 |
+
'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
|
| 833 |
+
'bg-white dark:bg-[#0A0A0A]',
|
| 834 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 835 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
| 836 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
| 837 |
+
'text-bolt-elements-textPrimary',
|
| 838 |
+
)}
|
| 839 |
+
>
|
| 840 |
+
<div className={classNames(format.icon, 'w-5 h-5')} />
|
| 841 |
+
<div>
|
| 842 |
+
<div className="font-medium">{format.label}</div>
|
| 843 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
| 844 |
+
{format.id === 'json' && 'Export as a structured JSON file'}
|
| 845 |
+
{format.id === 'csv' && 'Export as a CSV spreadsheet'}
|
| 846 |
+
{format.id === 'pdf' && 'Export as a formatted PDF document'}
|
| 847 |
+
{format.id === 'txt' && 'Export as a formatted text file'}
|
| 848 |
+
</div>
|
| 849 |
+
</div>
|
| 850 |
+
</button>
|
| 851 |
+
))}
|
| 852 |
+
</div>
|
| 853 |
+
</div>
|
| 854 |
+
</Dialog>
|
| 855 |
+
</DialogRoot>
|
| 856 |
+
);
|
| 857 |
+
};
|
| 858 |
+
|
| 859 |
+
return (
|
| 860 |
+
<div className="flex h-full flex-col gap-6">
|
| 861 |
+
<div className="flex items-center justify-between">
|
| 862 |
+
<DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
|
| 863 |
+
<DropdownMenu.Trigger asChild>
|
| 864 |
+
<button
|
| 865 |
+
className={classNames(
|
| 866 |
+
'flex items-center gap-2',
|
| 867 |
+
'rounded-lg px-3 py-1.5',
|
| 868 |
+
'text-sm text-gray-900 dark:text-white',
|
| 869 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 870 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 871 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 872 |
+
'transition-all duration-200',
|
| 873 |
+
)}
|
| 874 |
+
>
|
| 875 |
+
<span
|
| 876 |
+
className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
|
| 877 |
+
style={{ color: selectedLevelOption?.color }}
|
| 878 |
+
/>
|
| 879 |
+
{selectedLevelOption?.label || 'All Types'}
|
| 880 |
+
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
| 881 |
+
</button>
|
| 882 |
+
</DropdownMenu.Trigger>
|
| 883 |
+
|
| 884 |
+
<DropdownMenu.Portal>
|
| 885 |
+
<DropdownMenu.Content
|
| 886 |
+
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 887 |
+
sideOffset={5}
|
| 888 |
+
align="start"
|
| 889 |
+
side="bottom"
|
| 890 |
+
>
|
| 891 |
+
{logLevelOptions.map((option) => (
|
| 892 |
+
<DropdownMenu.Item
|
| 893 |
+
key={option.value}
|
| 894 |
+
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
|
| 895 |
+
onClick={() => handleLevelFilterChange(option.value)}
|
| 896 |
+
>
|
| 897 |
+
<div className="mr-3 flex h-5 w-5 items-center justify-center">
|
| 898 |
+
<div
|
| 899 |
+
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
| 900 |
+
style={{ color: option.color }}
|
| 901 |
+
/>
|
| 902 |
+
</div>
|
| 903 |
+
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
| 904 |
+
</DropdownMenu.Item>
|
| 905 |
+
))}
|
| 906 |
+
</DropdownMenu.Content>
|
| 907 |
+
</DropdownMenu.Portal>
|
| 908 |
+
</DropdownMenu.Root>
|
| 909 |
+
|
| 910 |
+
<div className="flex items-center gap-4">
|
| 911 |
+
<div className="flex items-center gap-2">
|
| 912 |
+
<Switch
|
| 913 |
+
checked={showTimestamps}
|
| 914 |
+
onCheckedChange={(value) => handlePreferenceChange('timestamps', value)}
|
| 915 |
+
className="data-[state=checked]:bg-purple-500"
|
| 916 |
+
/>
|
| 917 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Show Timestamps</span>
|
| 918 |
+
</div>
|
| 919 |
+
|
| 920 |
+
<div className="flex items-center gap-2">
|
| 921 |
+
<Switch
|
| 922 |
+
checked={use24Hour}
|
| 923 |
+
onCheckedChange={(value) => handlePreferenceChange('24hour', value)}
|
| 924 |
+
className="data-[state=checked]:bg-purple-500"
|
| 925 |
+
/>
|
| 926 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">24h Time</span>
|
| 927 |
+
</div>
|
| 928 |
+
|
| 929 |
+
<div className="flex items-center gap-2">
|
| 930 |
+
<Switch
|
| 931 |
+
checked={autoExpand}
|
| 932 |
+
onCheckedChange={(value) => handlePreferenceChange('autoExpand', value)}
|
| 933 |
+
className="data-[state=checked]:bg-purple-500"
|
| 934 |
+
/>
|
| 935 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Auto Expand</span>
|
| 936 |
+
</div>
|
| 937 |
+
|
| 938 |
+
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
|
| 939 |
+
|
| 940 |
+
<button
|
| 941 |
+
onClick={handleRefresh}
|
| 942 |
+
className={classNames(
|
| 943 |
+
'group flex items-center gap-2',
|
| 944 |
+
'rounded-lg px-3 py-1.5',
|
| 945 |
+
'text-sm text-gray-900 dark:text-white',
|
| 946 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 947 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 948 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 949 |
+
'transition-all duration-200',
|
| 950 |
+
{ 'animate-spin': isRefreshing },
|
| 951 |
+
)}
|
| 952 |
+
>
|
| 953 |
+
<span className="i-ph:arrows-clockwise text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
| 954 |
+
Refresh
|
| 955 |
+
</button>
|
| 956 |
+
|
| 957 |
+
<ExportButton />
|
| 958 |
+
</div>
|
| 959 |
+
</div>
|
| 960 |
+
|
| 961 |
+
<div className="flex flex-col gap-4">
|
| 962 |
+
<div className="relative">
|
| 963 |
+
<input
|
| 964 |
+
type="text"
|
| 965 |
+
placeholder="Search logs..."
|
| 966 |
+
value={searchQuery}
|
| 967 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 968 |
+
className={classNames(
|
| 969 |
+
'w-full px-4 py-2 pl-10 rounded-lg',
|
| 970 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 971 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 972 |
+
'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
|
| 973 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
|
| 974 |
+
'transition-all duration-200',
|
| 975 |
+
)}
|
| 976 |
+
/>
|
| 977 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
| 978 |
+
<div className="i-ph:magnifying-glass text-lg text-gray-500 dark:text-gray-400" />
|
| 979 |
+
</div>
|
| 980 |
+
</div>
|
| 981 |
+
|
| 982 |
+
{filteredLogs.length === 0 ? (
|
| 983 |
+
<motion.div
|
| 984 |
+
initial={{ opacity: 0, y: 20 }}
|
| 985 |
+
animate={{ opacity: 1, y: 0 }}
|
| 986 |
+
className={classNames(
|
| 987 |
+
'flex flex-col items-center justify-center gap-4',
|
| 988 |
+
'rounded-lg p-8 text-center',
|
| 989 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 990 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 991 |
+
)}
|
| 992 |
+
>
|
| 993 |
+
<span className="i-ph:clipboard-text text-4xl text-gray-400 dark:text-gray-600" />
|
| 994 |
+
<div className="flex flex-col gap-1">
|
| 995 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Logs Found</h3>
|
| 996 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search or filters</p>
|
| 997 |
+
</div>
|
| 998 |
+
</motion.div>
|
| 999 |
+
) : (
|
| 1000 |
+
filteredLogs.map((log) => (
|
| 1001 |
+
<LogEntryItem
|
| 1002 |
+
key={log.id}
|
| 1003 |
+
log={log}
|
| 1004 |
+
isExpanded={autoExpand}
|
| 1005 |
+
use24Hour={use24Hour}
|
| 1006 |
+
showTimestamp={showTimestamps}
|
| 1007 |
+
/>
|
| 1008 |
+
))
|
| 1009 |
+
)}
|
| 1010 |
+
</div>
|
| 1011 |
+
</div>
|
| 1012 |
+
);
|
| 1013 |
+
}
|
app/components/@settings/tabs/features/FeaturesTab.tsx
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Remove unused imports
|
| 2 |
+
import React, { memo, useCallback } from 'react';
|
| 3 |
+
import { motion } from 'framer-motion';
|
| 4 |
+
import { Switch } from '~/components/ui/Switch';
|
| 5 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import { toast } from 'react-toastify';
|
| 8 |
+
import { PromptLibrary } from '~/lib/common/prompt-library';
|
| 9 |
+
|
| 10 |
+
interface FeatureToggle {
|
| 11 |
+
id: string;
|
| 12 |
+
title: string;
|
| 13 |
+
description: string;
|
| 14 |
+
icon: string;
|
| 15 |
+
enabled: boolean;
|
| 16 |
+
beta?: boolean;
|
| 17 |
+
experimental?: boolean;
|
| 18 |
+
tooltip?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const FeatureCard = memo(
|
| 22 |
+
({
|
| 23 |
+
feature,
|
| 24 |
+
index,
|
| 25 |
+
onToggle,
|
| 26 |
+
}: {
|
| 27 |
+
feature: FeatureToggle;
|
| 28 |
+
index: number;
|
| 29 |
+
onToggle: (id: string, enabled: boolean) => void;
|
| 30 |
+
}) => (
|
| 31 |
+
<motion.div
|
| 32 |
+
key={feature.id}
|
| 33 |
+
layoutId={feature.id}
|
| 34 |
+
className={classNames(
|
| 35 |
+
'relative group cursor-pointer',
|
| 36 |
+
'bg-bolt-elements-background-depth-2',
|
| 37 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 38 |
+
'transition-colors duration-200',
|
| 39 |
+
'rounded-lg overflow-hidden',
|
| 40 |
+
)}
|
| 41 |
+
initial={{ opacity: 0, y: 20 }}
|
| 42 |
+
animate={{ opacity: 1, y: 0 }}
|
| 43 |
+
transition={{ delay: index * 0.1 }}
|
| 44 |
+
>
|
| 45 |
+
<div className="p-4">
|
| 46 |
+
<div className="flex items-center justify-between">
|
| 47 |
+
<div className="flex items-center gap-3">
|
| 48 |
+
<div className={classNames(feature.icon, 'w-5 h-5 text-bolt-elements-textSecondary')} />
|
| 49 |
+
<div className="flex items-center gap-2">
|
| 50 |
+
<h4 className="font-medium text-bolt-elements-textPrimary">{feature.title}</h4>
|
| 51 |
+
{feature.beta && (
|
| 52 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">Beta</span>
|
| 53 |
+
)}
|
| 54 |
+
{feature.experimental && (
|
| 55 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-orange-500/10 text-orange-500 font-medium">
|
| 56 |
+
Experimental
|
| 57 |
+
</span>
|
| 58 |
+
)}
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
<Switch checked={feature.enabled} onCheckedChange={(checked) => onToggle(feature.id, checked)} />
|
| 62 |
+
</div>
|
| 63 |
+
<p className="mt-2 text-sm text-bolt-elements-textSecondary">{feature.description}</p>
|
| 64 |
+
{feature.tooltip && <p className="mt-1 text-xs text-bolt-elements-textTertiary">{feature.tooltip}</p>}
|
| 65 |
+
</div>
|
| 66 |
+
</motion.div>
|
| 67 |
+
),
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
const FeatureSection = memo(
|
| 71 |
+
({
|
| 72 |
+
title,
|
| 73 |
+
features,
|
| 74 |
+
icon,
|
| 75 |
+
description,
|
| 76 |
+
onToggleFeature,
|
| 77 |
+
}: {
|
| 78 |
+
title: string;
|
| 79 |
+
features: FeatureToggle[];
|
| 80 |
+
icon: string;
|
| 81 |
+
description: string;
|
| 82 |
+
onToggleFeature: (id: string, enabled: boolean) => void;
|
| 83 |
+
}) => (
|
| 84 |
+
<motion.div
|
| 85 |
+
layout
|
| 86 |
+
className="flex flex-col gap-4"
|
| 87 |
+
initial={{ opacity: 0, y: 20 }}
|
| 88 |
+
animate={{ opacity: 1, y: 0 }}
|
| 89 |
+
transition={{ duration: 0.3 }}
|
| 90 |
+
>
|
| 91 |
+
<div className="flex items-center gap-3">
|
| 92 |
+
<div className={classNames(icon, 'text-xl text-purple-500')} />
|
| 93 |
+
<div>
|
| 94 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">{title}</h3>
|
| 95 |
+
<p className="text-sm text-bolt-elements-textSecondary">{description}</p>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 100 |
+
{features.map((feature, index) => (
|
| 101 |
+
<FeatureCard key={feature.id} feature={feature} index={index} onToggle={onToggleFeature} />
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
</motion.div>
|
| 105 |
+
),
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
export default function FeaturesTab() {
|
| 109 |
+
const {
|
| 110 |
+
autoSelectTemplate,
|
| 111 |
+
isLatestBranch,
|
| 112 |
+
contextOptimizationEnabled,
|
| 113 |
+
eventLogs,
|
| 114 |
+
setAutoSelectTemplate,
|
| 115 |
+
enableLatestBranch,
|
| 116 |
+
enableContextOptimization,
|
| 117 |
+
setEventLogs,
|
| 118 |
+
setPromptId,
|
| 119 |
+
promptId,
|
| 120 |
+
} = useSettings();
|
| 121 |
+
|
| 122 |
+
// Enable features by default on first load
|
| 123 |
+
React.useEffect(() => {
|
| 124 |
+
// Only set defaults if values are undefined
|
| 125 |
+
if (isLatestBranch === undefined) {
|
| 126 |
+
enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if (contextOptimizationEnabled === undefined) {
|
| 130 |
+
enableContextOptimization(true); // Default: ON - Enable context optimization
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
if (autoSelectTemplate === undefined) {
|
| 134 |
+
setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (promptId === undefined) {
|
| 138 |
+
setPromptId('default'); // Default: 'default'
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
if (eventLogs === undefined) {
|
| 142 |
+
setEventLogs(true); // Default: ON - Enable event logging
|
| 143 |
+
}
|
| 144 |
+
}, []); // Only run once on component mount
|
| 145 |
+
|
| 146 |
+
const handleToggleFeature = useCallback(
|
| 147 |
+
(id: string, enabled: boolean) => {
|
| 148 |
+
switch (id) {
|
| 149 |
+
case 'latestBranch': {
|
| 150 |
+
enableLatestBranch(enabled);
|
| 151 |
+
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
| 152 |
+
break;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
case 'autoSelectTemplate': {
|
| 156 |
+
setAutoSelectTemplate(enabled);
|
| 157 |
+
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
|
| 158 |
+
break;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
case 'contextOptimization': {
|
| 162 |
+
enableContextOptimization(enabled);
|
| 163 |
+
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
|
| 164 |
+
break;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
case 'eventLogs': {
|
| 168 |
+
setEventLogs(enabled);
|
| 169 |
+
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
|
| 170 |
+
break;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
default:
|
| 174 |
+
break;
|
| 175 |
+
}
|
| 176 |
+
},
|
| 177 |
+
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
const features = {
|
| 181 |
+
stable: [
|
| 182 |
+
{
|
| 183 |
+
id: 'latestBranch',
|
| 184 |
+
title: 'Main Branch Updates',
|
| 185 |
+
description: 'Get the latest updates from the main branch',
|
| 186 |
+
icon: 'i-ph:git-branch',
|
| 187 |
+
enabled: isLatestBranch,
|
| 188 |
+
tooltip: 'Enabled by default to receive updates from the main development branch',
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
id: 'autoSelectTemplate',
|
| 192 |
+
title: 'Auto Select Template',
|
| 193 |
+
description: 'Automatically select starter template',
|
| 194 |
+
icon: 'i-ph:selection',
|
| 195 |
+
enabled: autoSelectTemplate,
|
| 196 |
+
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
id: 'contextOptimization',
|
| 200 |
+
title: 'Context Optimization',
|
| 201 |
+
description: 'Optimize context for better responses',
|
| 202 |
+
icon: 'i-ph:brain',
|
| 203 |
+
enabled: contextOptimizationEnabled,
|
| 204 |
+
tooltip: 'Enabled by default for improved AI responses',
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
id: 'eventLogs',
|
| 208 |
+
title: 'Event Logging',
|
| 209 |
+
description: 'Enable detailed event logging and history',
|
| 210 |
+
icon: 'i-ph:list-bullets',
|
| 211 |
+
enabled: eventLogs,
|
| 212 |
+
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
|
| 213 |
+
},
|
| 214 |
+
],
|
| 215 |
+
beta: [],
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
return (
|
| 219 |
+
<div className="flex flex-col gap-8">
|
| 220 |
+
<FeatureSection
|
| 221 |
+
title="Core Features"
|
| 222 |
+
features={features.stable}
|
| 223 |
+
icon="i-ph:check-circle"
|
| 224 |
+
description="Essential features that are enabled by default for optimal performance"
|
| 225 |
+
onToggleFeature={handleToggleFeature}
|
| 226 |
+
/>
|
| 227 |
+
|
| 228 |
+
{features.beta.length > 0 && (
|
| 229 |
+
<FeatureSection
|
| 230 |
+
title="Beta Features"
|
| 231 |
+
features={features.beta}
|
| 232 |
+
icon="i-ph:test-tube"
|
| 233 |
+
description="New features that are ready for testing but may have some rough edges"
|
| 234 |
+
onToggleFeature={handleToggleFeature}
|
| 235 |
+
/>
|
| 236 |
+
)}
|
| 237 |
+
|
| 238 |
+
<motion.div
|
| 239 |
+
layout
|
| 240 |
+
className={classNames(
|
| 241 |
+
'bg-bolt-elements-background-depth-2',
|
| 242 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 243 |
+
'transition-all duration-200',
|
| 244 |
+
'rounded-lg p-4',
|
| 245 |
+
'group',
|
| 246 |
+
)}
|
| 247 |
+
initial={{ opacity: 0, y: 20 }}
|
| 248 |
+
animate={{ opacity: 1, y: 0 }}
|
| 249 |
+
transition={{ delay: 0.3 }}
|
| 250 |
+
>
|
| 251 |
+
<div className="flex items-center gap-4">
|
| 252 |
+
<div
|
| 253 |
+
className={classNames(
|
| 254 |
+
'p-2 rounded-lg text-xl',
|
| 255 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
| 256 |
+
'transition-colors duration-200',
|
| 257 |
+
'text-purple-500',
|
| 258 |
+
)}
|
| 259 |
+
>
|
| 260 |
+
<div className="i-ph:book" />
|
| 261 |
+
</div>
|
| 262 |
+
<div className="flex-1">
|
| 263 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
| 264 |
+
Prompt Library
|
| 265 |
+
</h4>
|
| 266 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
| 267 |
+
Choose a prompt from the library to use as the system prompt
|
| 268 |
+
</p>
|
| 269 |
+
</div>
|
| 270 |
+
<select
|
| 271 |
+
value={promptId}
|
| 272 |
+
onChange={(e) => {
|
| 273 |
+
setPromptId(e.target.value);
|
| 274 |
+
toast.success('Prompt template updated');
|
| 275 |
+
}}
|
| 276 |
+
className={classNames(
|
| 277 |
+
'p-2 rounded-lg text-sm min-w-[200px]',
|
| 278 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 279 |
+
'text-bolt-elements-textPrimary',
|
| 280 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 281 |
+
'group-hover:border-purple-500/30',
|
| 282 |
+
'transition-all duration-200',
|
| 283 |
+
)}
|
| 284 |
+
>
|
| 285 |
+
{PromptLibrary.getList().map((x) => (
|
| 286 |
+
<option key={x.id} value={x.id}>
|
| 287 |
+
{x.label}
|
| 288 |
+
</option>
|
| 289 |
+
))}
|
| 290 |
+
</select>
|
| 291 |
+
</div>
|
| 292 |
+
</motion.div>
|
| 293 |
+
</div>
|
| 294 |
+
);
|
| 295 |
+
}
|
app/components/@settings/tabs/notifications/NotificationsTab.tsx
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { logStore } from '~/lib/stores/logs';
|
| 4 |
+
import { useStore } from '@nanostores/react';
|
| 5 |
+
import { formatDistanceToNow } from 'date-fns';
|
| 6 |
+
import { classNames } from '~/utils/classNames';
|
| 7 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
| 8 |
+
|
| 9 |
+
interface NotificationDetails {
|
| 10 |
+
type?: string;
|
| 11 |
+
message?: string;
|
| 12 |
+
currentVersion?: string;
|
| 13 |
+
latestVersion?: string;
|
| 14 |
+
branch?: string;
|
| 15 |
+
updateUrl?: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
|
| 19 |
+
|
| 20 |
+
const NotificationsTab = () => {
|
| 21 |
+
const [filter, setFilter] = useState<FilterType>('all');
|
| 22 |
+
const logs = useStore(logStore.logs);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const startTime = performance.now();
|
| 26 |
+
|
| 27 |
+
return () => {
|
| 28 |
+
const duration = performance.now() - startTime;
|
| 29 |
+
logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
|
| 30 |
+
};
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
const handleClearNotifications = () => {
|
| 34 |
+
const count = Object.keys(logs).length;
|
| 35 |
+
logStore.logInfo('Cleared notifications', {
|
| 36 |
+
type: 'notification_clear',
|
| 37 |
+
message: `Cleared ${count} notifications`,
|
| 38 |
+
clearedCount: count,
|
| 39 |
+
component: 'notifications',
|
| 40 |
+
});
|
| 41 |
+
logStore.clearLogs();
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const handleUpdateAction = (updateUrl: string) => {
|
| 45 |
+
logStore.logInfo('Update link clicked', {
|
| 46 |
+
type: 'update_click',
|
| 47 |
+
message: 'User clicked update link',
|
| 48 |
+
updateUrl,
|
| 49 |
+
component: 'notifications',
|
| 50 |
+
});
|
| 51 |
+
window.open(updateUrl, '_blank');
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleFilterChange = (newFilter: FilterType) => {
|
| 55 |
+
logStore.logInfo('Notification filter changed', {
|
| 56 |
+
type: 'filter_change',
|
| 57 |
+
message: `Filter changed to ${newFilter}`,
|
| 58 |
+
previousFilter: filter,
|
| 59 |
+
newFilter,
|
| 60 |
+
component: 'notifications',
|
| 61 |
+
});
|
| 62 |
+
setFilter(newFilter);
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const filteredLogs = Object.values(logs)
|
| 66 |
+
.filter((log) => {
|
| 67 |
+
if (filter === 'all') {
|
| 68 |
+
return true;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (filter === 'update') {
|
| 72 |
+
return log.details?.type === 'update';
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if (filter === 'system') {
|
| 76 |
+
return log.category === 'system';
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (filter === 'provider') {
|
| 80 |
+
return log.category === 'provider';
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if (filter === 'network') {
|
| 84 |
+
return log.category === 'network';
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return log.level === filter;
|
| 88 |
+
})
|
| 89 |
+
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
| 90 |
+
|
| 91 |
+
const getNotificationStyle = (level: string, type?: string) => {
|
| 92 |
+
if (type === 'update') {
|
| 93 |
+
return {
|
| 94 |
+
icon: 'i-ph:arrow-circle-up',
|
| 95 |
+
color: 'text-purple-500 dark:text-purple-400',
|
| 96 |
+
bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 97 |
+
};
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
switch (level) {
|
| 101 |
+
case 'error':
|
| 102 |
+
return {
|
| 103 |
+
icon: 'i-ph:warning-circle',
|
| 104 |
+
color: 'text-red-500 dark:text-red-400',
|
| 105 |
+
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
| 106 |
+
};
|
| 107 |
+
case 'warning':
|
| 108 |
+
return {
|
| 109 |
+
icon: 'i-ph:warning',
|
| 110 |
+
color: 'text-yellow-500 dark:text-yellow-400',
|
| 111 |
+
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
| 112 |
+
};
|
| 113 |
+
case 'info':
|
| 114 |
+
return {
|
| 115 |
+
icon: 'i-ph:info',
|
| 116 |
+
color: 'text-blue-500 dark:text-blue-400',
|
| 117 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
| 118 |
+
};
|
| 119 |
+
default:
|
| 120 |
+
return {
|
| 121 |
+
icon: 'i-ph:bell',
|
| 122 |
+
color: 'text-gray-500 dark:text-gray-400',
|
| 123 |
+
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const renderNotificationDetails = (details: NotificationDetails) => {
|
| 129 |
+
if (details.type === 'update') {
|
| 130 |
+
return (
|
| 131 |
+
<div className="flex flex-col gap-2">
|
| 132 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p>
|
| 133 |
+
<div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-500">
|
| 134 |
+
<p>Current Version: {details.currentVersion}</p>
|
| 135 |
+
<p>Latest Version: {details.latestVersion}</p>
|
| 136 |
+
<p>Branch: {details.branch}</p>
|
| 137 |
+
</div>
|
| 138 |
+
<button
|
| 139 |
+
onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
|
| 140 |
+
className={classNames(
|
| 141 |
+
'mt-2 inline-flex items-center gap-2',
|
| 142 |
+
'rounded-lg px-3 py-1.5',
|
| 143 |
+
'text-sm font-medium',
|
| 144 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 145 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 146 |
+
'text-gray-900 dark:text-white',
|
| 147 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 148 |
+
'transition-all duration-200',
|
| 149 |
+
)}
|
| 150 |
+
>
|
| 151 |
+
<span className="i-ph:git-branch text-lg" />
|
| 152 |
+
View Changes
|
| 153 |
+
</button>
|
| 154 |
+
</div>
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
|
| 162 |
+
{ id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
|
| 163 |
+
{ id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
|
| 164 |
+
{ id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
|
| 165 |
+
{ id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
|
| 166 |
+
{ id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
|
| 167 |
+
{ id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
|
| 168 |
+
{ id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
|
| 169 |
+
{ id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
|
| 170 |
+
];
|
| 171 |
+
|
| 172 |
+
return (
|
| 173 |
+
<div className="flex h-full flex-col gap-6">
|
| 174 |
+
<div className="flex items-center justify-between">
|
| 175 |
+
<DropdownMenu.Root>
|
| 176 |
+
<DropdownMenu.Trigger asChild>
|
| 177 |
+
<button
|
| 178 |
+
className={classNames(
|
| 179 |
+
'flex items-center gap-2',
|
| 180 |
+
'rounded-lg px-3 py-1.5',
|
| 181 |
+
'text-sm text-gray-900 dark:text-white',
|
| 182 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 183 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 184 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 185 |
+
'transition-all duration-200',
|
| 186 |
+
)}
|
| 187 |
+
>
|
| 188 |
+
<span
|
| 189 |
+
className={classNames('text-lg', filterOptions.find((opt) => opt.id === filter)?.icon || 'i-ph:funnel')}
|
| 190 |
+
style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
|
| 191 |
+
/>
|
| 192 |
+
{filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
|
| 193 |
+
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
| 194 |
+
</button>
|
| 195 |
+
</DropdownMenu.Trigger>
|
| 196 |
+
|
| 197 |
+
<DropdownMenu.Portal>
|
| 198 |
+
<DropdownMenu.Content
|
| 199 |
+
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 200 |
+
sideOffset={5}
|
| 201 |
+
align="start"
|
| 202 |
+
side="bottom"
|
| 203 |
+
>
|
| 204 |
+
{filterOptions.map((option) => (
|
| 205 |
+
<DropdownMenu.Item
|
| 206 |
+
key={option.id}
|
| 207 |
+
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
|
| 208 |
+
onClick={() => handleFilterChange(option.id)}
|
| 209 |
+
>
|
| 210 |
+
<div className="mr-3 flex h-5 w-5 items-center justify-center">
|
| 211 |
+
<div
|
| 212 |
+
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
| 213 |
+
style={{ color: option.color }}
|
| 214 |
+
/>
|
| 215 |
+
</div>
|
| 216 |
+
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
| 217 |
+
</DropdownMenu.Item>
|
| 218 |
+
))}
|
| 219 |
+
</DropdownMenu.Content>
|
| 220 |
+
</DropdownMenu.Portal>
|
| 221 |
+
</DropdownMenu.Root>
|
| 222 |
+
|
| 223 |
+
<button
|
| 224 |
+
onClick={handleClearNotifications}
|
| 225 |
+
className={classNames(
|
| 226 |
+
'group flex items-center gap-2',
|
| 227 |
+
'rounded-lg px-3 py-1.5',
|
| 228 |
+
'text-sm text-gray-900 dark:text-white',
|
| 229 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 230 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 231 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
| 232 |
+
'transition-all duration-200',
|
| 233 |
+
)}
|
| 234 |
+
>
|
| 235 |
+
<span className="i-ph:trash text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
| 236 |
+
Clear All
|
| 237 |
+
</button>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<div className="flex flex-col gap-4">
|
| 241 |
+
{filteredLogs.length === 0 ? (
|
| 242 |
+
<motion.div
|
| 243 |
+
initial={{ opacity: 0, y: 20 }}
|
| 244 |
+
animate={{ opacity: 1, y: 0 }}
|
| 245 |
+
className={classNames(
|
| 246 |
+
'flex flex-col items-center justify-center gap-4',
|
| 247 |
+
'rounded-lg p-8 text-center',
|
| 248 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 249 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 250 |
+
)}
|
| 251 |
+
>
|
| 252 |
+
<span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
|
| 253 |
+
<div className="flex flex-col gap-1">
|
| 254 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Notifications</h3>
|
| 255 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
|
| 256 |
+
</div>
|
| 257 |
+
</motion.div>
|
| 258 |
+
) : (
|
| 259 |
+
filteredLogs.map((log) => {
|
| 260 |
+
const style = getNotificationStyle(log.level, log.details?.type);
|
| 261 |
+
return (
|
| 262 |
+
<motion.div
|
| 263 |
+
key={log.id}
|
| 264 |
+
initial={{ opacity: 0, y: 20 }}
|
| 265 |
+
animate={{ opacity: 1, y: 0 }}
|
| 266 |
+
className={classNames(
|
| 267 |
+
'flex flex-col gap-2',
|
| 268 |
+
'rounded-lg p-4',
|
| 269 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 270 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 271 |
+
style.bg,
|
| 272 |
+
'transition-all duration-200',
|
| 273 |
+
)}
|
| 274 |
+
>
|
| 275 |
+
<div className="flex items-start justify-between gap-4">
|
| 276 |
+
<div className="flex items-start gap-3">
|
| 277 |
+
<span className={classNames('text-lg', style.icon, style.color)} />
|
| 278 |
+
<div className="flex flex-col gap-1">
|
| 279 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</h3>
|
| 280 |
+
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
|
| 281 |
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
| 282 |
+
Category: {log.category}
|
| 283 |
+
{log.subCategory ? ` > ${log.subCategory}` : ''}
|
| 284 |
+
</p>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
<time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
| 288 |
+
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
|
| 289 |
+
</time>
|
| 290 |
+
</div>
|
| 291 |
+
</motion.div>
|
| 292 |
+
);
|
| 293 |
+
})
|
| 294 |
+
)}
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
);
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
export default NotificationsTab;
|
app/components/@settings/tabs/profile/ProfileTab.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
import { useStore } from '@nanostores/react';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { profileStore, updateProfile } from '~/lib/stores/profile';
|
| 5 |
+
import { toast } from 'react-toastify';
|
| 6 |
+
import { debounce } from '~/utils/debounce';
|
| 7 |
+
|
| 8 |
+
export default function ProfileTab() {
|
| 9 |
+
const profile = useStore(profileStore);
|
| 10 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 11 |
+
|
| 12 |
+
// Create debounced update functions
|
| 13 |
+
const debouncedUpdate = useCallback(
|
| 14 |
+
debounce((field: 'username' | 'bio', value: string) => {
|
| 15 |
+
updateProfile({ [field]: value });
|
| 16 |
+
toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
|
| 17 |
+
}, 1000),
|
| 18 |
+
[],
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 22 |
+
const file = e.target.files?.[0];
|
| 23 |
+
|
| 24 |
+
if (!file) {
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
setIsUploading(true);
|
| 30 |
+
|
| 31 |
+
// Convert the file to base64
|
| 32 |
+
const reader = new FileReader();
|
| 33 |
+
|
| 34 |
+
reader.onloadend = () => {
|
| 35 |
+
const base64String = reader.result as string;
|
| 36 |
+
updateProfile({ avatar: base64String });
|
| 37 |
+
setIsUploading(false);
|
| 38 |
+
toast.success('Profile picture updated');
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
reader.onerror = () => {
|
| 42 |
+
console.error('Error reading file:', reader.error);
|
| 43 |
+
setIsUploading(false);
|
| 44 |
+
toast.error('Failed to update profile picture');
|
| 45 |
+
};
|
| 46 |
+
reader.readAsDataURL(file);
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.error('Error uploading avatar:', error);
|
| 49 |
+
setIsUploading(false);
|
| 50 |
+
toast.error('Failed to update profile picture');
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
|
| 55 |
+
// Update the store immediately for UI responsiveness
|
| 56 |
+
updateProfile({ [field]: value });
|
| 57 |
+
|
| 58 |
+
// Debounce the toast notification
|
| 59 |
+
debouncedUpdate(field, value);
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="max-w-2xl mx-auto">
|
| 64 |
+
<div className="space-y-6">
|
| 65 |
+
{/* Personal Information Section */}
|
| 66 |
+
<div>
|
| 67 |
+
{/* Avatar Upload */}
|
| 68 |
+
<div className="flex items-start gap-6 mb-8">
|
| 69 |
+
<div
|
| 70 |
+
className={classNames(
|
| 71 |
+
'w-24 h-24 rounded-full overflow-hidden',
|
| 72 |
+
'bg-gray-100 dark:bg-gray-800/50',
|
| 73 |
+
'flex items-center justify-center',
|
| 74 |
+
'ring-1 ring-gray-200 dark:ring-gray-700',
|
| 75 |
+
'relative group',
|
| 76 |
+
'transition-all duration-300 ease-out',
|
| 77 |
+
'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
|
| 78 |
+
'hover:shadow-lg hover:shadow-purple-500/10',
|
| 79 |
+
)}
|
| 80 |
+
>
|
| 81 |
+
{profile.avatar ? (
|
| 82 |
+
<img
|
| 83 |
+
src={profile.avatar}
|
| 84 |
+
alt="Profile"
|
| 85 |
+
className={classNames(
|
| 86 |
+
'w-full h-full object-cover',
|
| 87 |
+
'transition-all duration-300 ease-out',
|
| 88 |
+
'group-hover:scale-105 group-hover:brightness-90',
|
| 89 |
+
)}
|
| 90 |
+
/>
|
| 91 |
+
) : (
|
| 92 |
+
<div className="i-ph:robot-fill w-16 h-16 text-gray-400 dark:text-gray-500 transition-colors group-hover:text-purple-500/70 transform -translate-y-1" />
|
| 93 |
+
)}
|
| 94 |
+
|
| 95 |
+
<label
|
| 96 |
+
className={classNames(
|
| 97 |
+
'absolute inset-0',
|
| 98 |
+
'flex items-center justify-center',
|
| 99 |
+
'bg-black/0 group-hover:bg-black/40',
|
| 100 |
+
'cursor-pointer transition-all duration-300 ease-out',
|
| 101 |
+
isUploading ? 'cursor-wait' : '',
|
| 102 |
+
)}
|
| 103 |
+
>
|
| 104 |
+
<input
|
| 105 |
+
type="file"
|
| 106 |
+
accept="image/*"
|
| 107 |
+
className="hidden"
|
| 108 |
+
onChange={handleAvatarUpload}
|
| 109 |
+
disabled={isUploading}
|
| 110 |
+
/>
|
| 111 |
+
{isUploading ? (
|
| 112 |
+
<div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
|
| 113 |
+
) : (
|
| 114 |
+
<div className="i-ph:camera-plus w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300 ease-out transform group-hover:scale-110" />
|
| 115 |
+
)}
|
| 116 |
+
</label>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div className="flex-1 pt-1">
|
| 120 |
+
<label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
|
| 121 |
+
Profile Picture
|
| 122 |
+
</label>
|
| 123 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
{/* Username Input */}
|
| 128 |
+
<div className="mb-6">
|
| 129 |
+
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
|
| 130 |
+
<div className="relative group">
|
| 131 |
+
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
|
| 132 |
+
<div className="i-ph:user-circle-fill w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
|
| 133 |
+
</div>
|
| 134 |
+
<input
|
| 135 |
+
type="text"
|
| 136 |
+
value={profile.username}
|
| 137 |
+
onChange={(e) => handleProfileUpdate('username', e.target.value)}
|
| 138 |
+
className={classNames(
|
| 139 |
+
'w-full pl-11 pr-4 py-2.5 rounded-xl',
|
| 140 |
+
'bg-white dark:bg-gray-800/50',
|
| 141 |
+
'border border-gray-200 dark:border-gray-700/50',
|
| 142 |
+
'text-gray-900 dark:text-white',
|
| 143 |
+
'placeholder-gray-400 dark:placeholder-gray-500',
|
| 144 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
|
| 145 |
+
'transition-all duration-300 ease-out',
|
| 146 |
+
)}
|
| 147 |
+
placeholder="Enter your username"
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{/* Bio Input */}
|
| 153 |
+
<div className="mb-8">
|
| 154 |
+
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
|
| 155 |
+
<div className="relative group">
|
| 156 |
+
<div className="absolute left-3.5 top-3">
|
| 157 |
+
<div className="i-ph:text-aa w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
|
| 158 |
+
</div>
|
| 159 |
+
<textarea
|
| 160 |
+
value={profile.bio}
|
| 161 |
+
onChange={(e) => handleProfileUpdate('bio', e.target.value)}
|
| 162 |
+
className={classNames(
|
| 163 |
+
'w-full pl-11 pr-4 py-2.5 rounded-xl',
|
| 164 |
+
'bg-white dark:bg-gray-800/50',
|
| 165 |
+
'border border-gray-200 dark:border-gray-700/50',
|
| 166 |
+
'text-gray-900 dark:text-white',
|
| 167 |
+
'placeholder-gray-400 dark:placeholder-gray-500',
|
| 168 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
|
| 169 |
+
'transition-all duration-300 ease-out',
|
| 170 |
+
'resize-none',
|
| 171 |
+
'h-32',
|
| 172 |
+
)}
|
| 173 |
+
placeholder="Tell us about yourself"
|
| 174 |
+
/>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { Switch } from '~/components/ui/Switch';
|
| 3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 4 |
+
import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
| 5 |
+
import type { IProviderConfig } from '~/types/model';
|
| 6 |
+
import { logStore } from '~/lib/stores/logs';
|
| 7 |
+
import { motion } from 'framer-motion';
|
| 8 |
+
import { classNames } from '~/utils/classNames';
|
| 9 |
+
import { toast } from 'react-toastify';
|
| 10 |
+
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
| 11 |
+
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
|
| 12 |
+
import { BsRobot, BsCloud } from 'react-icons/bs';
|
| 13 |
+
import { TbBrain, TbCloudComputing } from 'react-icons/tb';
|
| 14 |
+
import { BiCodeBlock, BiChip } from 'react-icons/bi';
|
| 15 |
+
import { FaCloud, FaBrain } from 'react-icons/fa';
|
| 16 |
+
import type { IconType } from 'react-icons';
|
| 17 |
+
|
| 18 |
+
// Add type for provider names to ensure type safety
|
| 19 |
+
type ProviderName =
|
| 20 |
+
| 'AmazonBedrock'
|
| 21 |
+
| 'Anthropic'
|
| 22 |
+
| 'Cohere'
|
| 23 |
+
| 'Deepseek'
|
| 24 |
+
| 'Google'
|
| 25 |
+
| 'Groq'
|
| 26 |
+
| 'HuggingFace'
|
| 27 |
+
| 'Hyperbolic'
|
| 28 |
+
| 'Mistral'
|
| 29 |
+
| 'OpenAI'
|
| 30 |
+
| 'OpenRouter'
|
| 31 |
+
| 'Perplexity'
|
| 32 |
+
| 'Together'
|
| 33 |
+
| 'XAI';
|
| 34 |
+
|
| 35 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
| 36 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
| 37 |
+
AmazonBedrock: SiAmazon,
|
| 38 |
+
Anthropic: FaBrain,
|
| 39 |
+
Cohere: BiChip,
|
| 40 |
+
Deepseek: BiCodeBlock,
|
| 41 |
+
Google: SiGoogle,
|
| 42 |
+
Groq: BsCloud,
|
| 43 |
+
HuggingFace: SiHuggingface,
|
| 44 |
+
Hyperbolic: TbCloudComputing,
|
| 45 |
+
Mistral: TbBrain,
|
| 46 |
+
OpenAI: SiOpenai,
|
| 47 |
+
OpenRouter: FaCloud,
|
| 48 |
+
Perplexity: SiPerplexity,
|
| 49 |
+
Together: BsCloud,
|
| 50 |
+
XAI: BsRobot,
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
| 54 |
+
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
|
| 55 |
+
Anthropic: 'Access Claude and other Anthropic models',
|
| 56 |
+
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const CloudProvidersTab = () => {
|
| 60 |
+
const settings = useSettings();
|
| 61 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
| 62 |
+
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
| 63 |
+
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
|
| 64 |
+
|
| 65 |
+
// Load and filter providers
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
const newFilteredProviders = Object.entries(settings.providers || {})
|
| 68 |
+
.filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
|
| 69 |
+
.map(([key, value]) => ({
|
| 70 |
+
name: key,
|
| 71 |
+
settings: value.settings,
|
| 72 |
+
staticModels: value.staticModels || [],
|
| 73 |
+
getDynamicModels: value.getDynamicModels,
|
| 74 |
+
getApiKeyLink: value.getApiKeyLink,
|
| 75 |
+
labelForGetApiKey: value.labelForGetApiKey,
|
| 76 |
+
icon: value.icon,
|
| 77 |
+
}));
|
| 78 |
+
|
| 79 |
+
const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
|
| 80 |
+
setFilteredProviders(sorted);
|
| 81 |
+
|
| 82 |
+
// Update category enabled state
|
| 83 |
+
const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
|
| 84 |
+
setCategoryEnabled(allEnabled);
|
| 85 |
+
}, [settings.providers]);
|
| 86 |
+
|
| 87 |
+
const handleToggleCategory = useCallback(
|
| 88 |
+
(enabled: boolean) => {
|
| 89 |
+
// Update all providers
|
| 90 |
+
filteredProviders.forEach((provider) => {
|
| 91 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
setCategoryEnabled(enabled);
|
| 95 |
+
toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
|
| 96 |
+
},
|
| 97 |
+
[filteredProviders, settings],
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
const handleToggleProvider = useCallback(
|
| 101 |
+
(provider: IProviderConfig, enabled: boolean) => {
|
| 102 |
+
// Update the provider settings in the store
|
| 103 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
| 104 |
+
|
| 105 |
+
if (enabled) {
|
| 106 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
| 107 |
+
toast.success(`${provider.name} enabled`);
|
| 108 |
+
} else {
|
| 109 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
| 110 |
+
toast.success(`${provider.name} disabled`);
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
[settings],
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
const handleUpdateBaseUrl = useCallback(
|
| 117 |
+
(provider: IProviderConfig, baseUrl: string) => {
|
| 118 |
+
const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
|
| 119 |
+
|
| 120 |
+
// Update the provider settings in the store
|
| 121 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
| 122 |
+
|
| 123 |
+
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
| 124 |
+
provider: provider.name,
|
| 125 |
+
baseUrl: newBaseUrl,
|
| 126 |
+
});
|
| 127 |
+
toast.success(`${provider.name} base URL updated`);
|
| 128 |
+
setEditingProvider(null);
|
| 129 |
+
},
|
| 130 |
+
[settings],
|
| 131 |
+
);
|
| 132 |
+
|
| 133 |
+
return (
|
| 134 |
+
<div className="space-y-6">
|
| 135 |
+
<motion.div
|
| 136 |
+
className="space-y-4"
|
| 137 |
+
initial={{ opacity: 0, y: 20 }}
|
| 138 |
+
animate={{ opacity: 1, y: 0 }}
|
| 139 |
+
transition={{ duration: 0.3 }}
|
| 140 |
+
>
|
| 141 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
| 142 |
+
<div className="flex items-center gap-2">
|
| 143 |
+
<div
|
| 144 |
+
className={classNames(
|
| 145 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
| 146 |
+
'bg-bolt-elements-background-depth-3',
|
| 147 |
+
'text-purple-500',
|
| 148 |
+
)}
|
| 149 |
+
>
|
| 150 |
+
<TbCloudComputing className="w-5 h-5" />
|
| 151 |
+
</div>
|
| 152 |
+
<div>
|
| 153 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Cloud Providers</h4>
|
| 154 |
+
<p className="text-sm text-bolt-elements-textSecondary">Connect to cloud-based AI models and services</p>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div className="flex items-center gap-2">
|
| 159 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All Cloud</span>
|
| 160 |
+
<Switch checked={categoryEnabled} onCheckedChange={handleToggleCategory} />
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 165 |
+
{filteredProviders.map((provider, index) => (
|
| 166 |
+
<motion.div
|
| 167 |
+
key={provider.name}
|
| 168 |
+
className={classNames(
|
| 169 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm',
|
| 170 |
+
'bg-bolt-elements-background-depth-2',
|
| 171 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 172 |
+
'transition-all duration-200',
|
| 173 |
+
'relative overflow-hidden group',
|
| 174 |
+
'flex flex-col',
|
| 175 |
+
)}
|
| 176 |
+
initial={{ opacity: 0, y: 20 }}
|
| 177 |
+
animate={{ opacity: 1, y: 0 }}
|
| 178 |
+
transition={{ delay: index * 0.1 }}
|
| 179 |
+
whileHover={{ scale: 1.02 }}
|
| 180 |
+
>
|
| 181 |
+
<div className="absolute top-0 right-0 p-2 flex gap-1">
|
| 182 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
| 183 |
+
<motion.span
|
| 184 |
+
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
|
| 185 |
+
whileHover={{ scale: 1.05 }}
|
| 186 |
+
whileTap={{ scale: 0.95 }}
|
| 187 |
+
>
|
| 188 |
+
Configurable
|
| 189 |
+
</motion.span>
|
| 190 |
+
)}
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div className="flex items-start gap-4 p-4">
|
| 194 |
+
<motion.div
|
| 195 |
+
className={classNames(
|
| 196 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
| 197 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
| 198 |
+
'transition-all duration-200',
|
| 199 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
| 200 |
+
)}
|
| 201 |
+
whileHover={{ scale: 1.1 }}
|
| 202 |
+
whileTap={{ scale: 0.9 }}
|
| 203 |
+
>
|
| 204 |
+
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
|
| 205 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
| 206 |
+
className: 'w-full h-full',
|
| 207 |
+
'aria-label': `${provider.name} logo`,
|
| 208 |
+
})}
|
| 209 |
+
</div>
|
| 210 |
+
</motion.div>
|
| 211 |
+
|
| 212 |
+
<div className="flex-1 min-w-0">
|
| 213 |
+
<div className="flex items-center justify-between gap-4 mb-2">
|
| 214 |
+
<div>
|
| 215 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
| 216 |
+
{provider.name}
|
| 217 |
+
</h4>
|
| 218 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
| 219 |
+
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
|
| 220 |
+
(URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
|
| 221 |
+
? 'Configure custom endpoint for this provider'
|
| 222 |
+
: 'Standard AI provider integration')}
|
| 223 |
+
</p>
|
| 224 |
+
</div>
|
| 225 |
+
<Switch
|
| 226 |
+
checked={provider.settings.enabled}
|
| 227 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
| 228 |
+
/>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
| 232 |
+
<motion.div
|
| 233 |
+
initial={{ opacity: 0, height: 0 }}
|
| 234 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 235 |
+
exit={{ opacity: 0, height: 0 }}
|
| 236 |
+
transition={{ duration: 0.2 }}
|
| 237 |
+
>
|
| 238 |
+
<div className="flex items-center gap-2 mt-4">
|
| 239 |
+
{editingProvider === provider.name ? (
|
| 240 |
+
<input
|
| 241 |
+
type="text"
|
| 242 |
+
defaultValue={provider.settings.baseUrl}
|
| 243 |
+
placeholder={`Enter ${provider.name} base URL`}
|
| 244 |
+
className={classNames(
|
| 245 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
| 246 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 247 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 248 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 249 |
+
'transition-all duration-200',
|
| 250 |
+
)}
|
| 251 |
+
onKeyDown={(e) => {
|
| 252 |
+
if (e.key === 'Enter') {
|
| 253 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
| 254 |
+
} else if (e.key === 'Escape') {
|
| 255 |
+
setEditingProvider(null);
|
| 256 |
+
}
|
| 257 |
+
}}
|
| 258 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
| 259 |
+
autoFocus
|
| 260 |
+
/>
|
| 261 |
+
) : (
|
| 262 |
+
<div
|
| 263 |
+
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
|
| 264 |
+
onClick={() => setEditingProvider(provider.name)}
|
| 265 |
+
>
|
| 266 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
| 267 |
+
<div className="i-ph:link text-sm" />
|
| 268 |
+
<span className="group-hover/url:text-purple-500 transition-colors">
|
| 269 |
+
{provider.settings.baseUrl || 'Click to set base URL'}
|
| 270 |
+
</span>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
)}
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
|
| 277 |
+
<div className="mt-2 text-xs text-green-500">
|
| 278 |
+
<div className="flex items-center gap-1">
|
| 279 |
+
<div className="i-ph:info" />
|
| 280 |
+
<span>Environment URL set in .env file</span>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
</motion.div>
|
| 285 |
+
)}
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<motion.div
|
| 290 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
| 291 |
+
animate={{
|
| 292 |
+
borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
| 293 |
+
scale: provider.settings.enabled ? 1 : 0.98,
|
| 294 |
+
}}
|
| 295 |
+
transition={{ duration: 0.2 }}
|
| 296 |
+
/>
|
| 297 |
+
</motion.div>
|
| 298 |
+
))}
|
| 299 |
+
</div>
|
| 300 |
+
</motion.div>
|
| 301 |
+
</div>
|
| 302 |
+
);
|
| 303 |
+
};
|
| 304 |
+
|
| 305 |
+
export default CloudProvidersTab;
|
app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { Switch } from '~/components/ui/Switch';
|
| 3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 4 |
+
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
| 5 |
+
import type { IProviderConfig } from '~/types/model';
|
| 6 |
+
import { logStore } from '~/lib/stores/logs';
|
| 7 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 8 |
+
import { classNames } from '~/utils/classNames';
|
| 9 |
+
import { BsRobot } from 'react-icons/bs';
|
| 10 |
+
import type { IconType } from 'react-icons';
|
| 11 |
+
import { BiChip } from 'react-icons/bi';
|
| 12 |
+
import { TbBrandOpenai } from 'react-icons/tb';
|
| 13 |
+
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
| 14 |
+
import { useToast } from '~/components/ui/use-toast';
|
| 15 |
+
import { Progress } from '~/components/ui/Progress';
|
| 16 |
+
import OllamaModelInstaller from './OllamaModelInstaller';
|
| 17 |
+
|
| 18 |
+
// Add type for provider names to ensure type safety
|
| 19 |
+
type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
|
| 20 |
+
|
| 21 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
| 22 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
| 23 |
+
Ollama: BsRobot,
|
| 24 |
+
LMStudio: BsRobot,
|
| 25 |
+
OpenAILike: TbBrandOpenai,
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
| 29 |
+
const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
|
| 30 |
+
Ollama: 'Run open-source models locally on your machine',
|
| 31 |
+
LMStudio: 'Local model inference with LM Studio',
|
| 32 |
+
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
// Add a constant for the Ollama API base URL
|
| 36 |
+
const OLLAMA_API_URL = 'http://127.0.0.1:11434';
|
| 37 |
+
|
| 38 |
+
interface OllamaModel {
|
| 39 |
+
name: string;
|
| 40 |
+
digest: string;
|
| 41 |
+
size: number;
|
| 42 |
+
modified_at: string;
|
| 43 |
+
details?: {
|
| 44 |
+
family: string;
|
| 45 |
+
parameter_size: string;
|
| 46 |
+
quantization_level: string;
|
| 47 |
+
};
|
| 48 |
+
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
|
| 49 |
+
error?: string;
|
| 50 |
+
newDigest?: string;
|
| 51 |
+
progress?: {
|
| 52 |
+
current: number;
|
| 53 |
+
total: number;
|
| 54 |
+
status: string;
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
interface OllamaPullResponse {
|
| 59 |
+
status: string;
|
| 60 |
+
completed?: number;
|
| 61 |
+
total?: number;
|
| 62 |
+
digest?: string;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
|
| 66 |
+
return (
|
| 67 |
+
typeof data === 'object' &&
|
| 68 |
+
data !== null &&
|
| 69 |
+
'status' in data &&
|
| 70 |
+
typeof (data as OllamaPullResponse).status === 'string'
|
| 71 |
+
);
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
export default function LocalProvidersTab() {
|
| 75 |
+
const { providers, updateProviderSettings } = useSettings();
|
| 76 |
+
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
| 77 |
+
const [categoryEnabled, setCategoryEnabled] = useState(false);
|
| 78 |
+
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
| 79 |
+
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
| 80 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
| 81 |
+
const { toast } = useToast();
|
| 82 |
+
|
| 83 |
+
// Effect to filter and sort providers
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
const newFilteredProviders = Object.entries(providers || {})
|
| 86 |
+
.filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
|
| 87 |
+
.map(([key, value]) => {
|
| 88 |
+
const provider = value as IProviderConfig;
|
| 89 |
+
const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
|
| 90 |
+
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
|
| 91 |
+
|
| 92 |
+
// Set base URL if provided by environment
|
| 93 |
+
if (envUrl && !provider.settings.baseUrl) {
|
| 94 |
+
updateProviderSettings(key, {
|
| 95 |
+
...provider.settings,
|
| 96 |
+
baseUrl: envUrl,
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
name: key,
|
| 102 |
+
settings: {
|
| 103 |
+
...provider.settings,
|
| 104 |
+
baseUrl: provider.settings.baseUrl || envUrl,
|
| 105 |
+
},
|
| 106 |
+
staticModels: provider.staticModels || [],
|
| 107 |
+
getDynamicModels: provider.getDynamicModels,
|
| 108 |
+
getApiKeyLink: provider.getApiKeyLink,
|
| 109 |
+
labelForGetApiKey: provider.labelForGetApiKey,
|
| 110 |
+
icon: provider.icon,
|
| 111 |
+
} as IProviderConfig;
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
// Custom sort function to ensure LMStudio appears before OpenAILike
|
| 115 |
+
const sorted = newFilteredProviders.sort((a, b) => {
|
| 116 |
+
if (a.name === 'LMStudio') {
|
| 117 |
+
return -1;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (b.name === 'LMStudio') {
|
| 121 |
+
return 1;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
if (a.name === 'OpenAILike') {
|
| 125 |
+
return 1;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
if (b.name === 'OpenAILike') {
|
| 129 |
+
return -1;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return a.name.localeCompare(b.name);
|
| 133 |
+
});
|
| 134 |
+
setFilteredProviders(sorted);
|
| 135 |
+
}, [providers, updateProviderSettings]);
|
| 136 |
+
|
| 137 |
+
// Add effect to update category toggle state based on provider states
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
|
| 140 |
+
setCategoryEnabled(newCategoryState);
|
| 141 |
+
}, [filteredProviders]);
|
| 142 |
+
|
| 143 |
+
// Fetch Ollama models when enabled
|
| 144 |
+
useEffect(() => {
|
| 145 |
+
const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
|
| 146 |
+
|
| 147 |
+
if (ollamaProvider?.settings.enabled) {
|
| 148 |
+
fetchOllamaModels();
|
| 149 |
+
}
|
| 150 |
+
}, [filteredProviders]);
|
| 151 |
+
|
| 152 |
+
const fetchOllamaModels = async () => {
|
| 153 |
+
try {
|
| 154 |
+
setIsLoadingModels(true);
|
| 155 |
+
|
| 156 |
+
const response = await fetch('http://127.0.0.1:11434/api/tags');
|
| 157 |
+
const data = (await response.json()) as { models: OllamaModel[] };
|
| 158 |
+
|
| 159 |
+
setOllamaModels(
|
| 160 |
+
data.models.map((model) => ({
|
| 161 |
+
...model,
|
| 162 |
+
status: 'idle' as const,
|
| 163 |
+
})),
|
| 164 |
+
);
|
| 165 |
+
} catch (error) {
|
| 166 |
+
console.error('Error fetching Ollama models:', error);
|
| 167 |
+
} finally {
|
| 168 |
+
setIsLoadingModels(false);
|
| 169 |
+
}
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
const updateOllamaModel = async (modelName: string): Promise<boolean> => {
|
| 173 |
+
try {
|
| 174 |
+
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
|
| 175 |
+
method: 'POST',
|
| 176 |
+
headers: { 'Content-Type': 'application/json' },
|
| 177 |
+
body: JSON.stringify({ name: modelName }),
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
if (!response.ok) {
|
| 181 |
+
throw new Error(`Failed to update ${modelName}`);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const reader = response.body?.getReader();
|
| 185 |
+
|
| 186 |
+
if (!reader) {
|
| 187 |
+
throw new Error('No response reader available');
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
while (true) {
|
| 191 |
+
const { done, value } = await reader.read();
|
| 192 |
+
|
| 193 |
+
if (done) {
|
| 194 |
+
break;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const text = new TextDecoder().decode(value);
|
| 198 |
+
const lines = text.split('\n').filter(Boolean);
|
| 199 |
+
|
| 200 |
+
for (const line of lines) {
|
| 201 |
+
const rawData = JSON.parse(line);
|
| 202 |
+
|
| 203 |
+
if (!isOllamaPullResponse(rawData)) {
|
| 204 |
+
console.error('Invalid response format:', rawData);
|
| 205 |
+
continue;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
setOllamaModels((current) =>
|
| 209 |
+
current.map((m) =>
|
| 210 |
+
m.name === modelName
|
| 211 |
+
? {
|
| 212 |
+
...m,
|
| 213 |
+
progress: {
|
| 214 |
+
current: rawData.completed || 0,
|
| 215 |
+
total: rawData.total || 0,
|
| 216 |
+
status: rawData.status,
|
| 217 |
+
},
|
| 218 |
+
newDigest: rawData.digest,
|
| 219 |
+
}
|
| 220 |
+
: m,
|
| 221 |
+
),
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
|
| 227 |
+
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
|
| 228 |
+
const updatedModel = updatedData.models.find((m) => m.name === modelName);
|
| 229 |
+
|
| 230 |
+
return updatedModel !== undefined;
|
| 231 |
+
} catch (error) {
|
| 232 |
+
console.error(`Error updating ${modelName}:`, error);
|
| 233 |
+
return false;
|
| 234 |
+
}
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
const handleToggleCategory = useCallback(
|
| 238 |
+
async (enabled: boolean) => {
|
| 239 |
+
filteredProviders.forEach((provider) => {
|
| 240 |
+
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
| 241 |
+
});
|
| 242 |
+
toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
|
| 243 |
+
},
|
| 244 |
+
[filteredProviders, updateProviderSettings],
|
| 245 |
+
);
|
| 246 |
+
|
| 247 |
+
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
| 248 |
+
updateProviderSettings(provider.name, {
|
| 249 |
+
...provider.settings,
|
| 250 |
+
enabled,
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
if (enabled) {
|
| 254 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
| 255 |
+
toast(`${provider.name} enabled`);
|
| 256 |
+
} else {
|
| 257 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
| 258 |
+
toast(`${provider.name} disabled`);
|
| 259 |
+
}
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
|
| 263 |
+
updateProviderSettings(provider.name, {
|
| 264 |
+
...provider.settings,
|
| 265 |
+
baseUrl: newBaseUrl,
|
| 266 |
+
});
|
| 267 |
+
toast(`${provider.name} base URL updated`);
|
| 268 |
+
setEditingProvider(null);
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
const handleUpdateOllamaModel = async (modelName: string) => {
|
| 272 |
+
const updateSuccess = await updateOllamaModel(modelName);
|
| 273 |
+
|
| 274 |
+
if (updateSuccess) {
|
| 275 |
+
toast(`Updated ${modelName}`);
|
| 276 |
+
} else {
|
| 277 |
+
toast(`Failed to update ${modelName}`);
|
| 278 |
+
}
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
const handleDeleteOllamaModel = async (modelName: string) => {
|
| 282 |
+
try {
|
| 283 |
+
const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
|
| 284 |
+
method: 'DELETE',
|
| 285 |
+
headers: {
|
| 286 |
+
'Content-Type': 'application/json',
|
| 287 |
+
},
|
| 288 |
+
body: JSON.stringify({ name: modelName }),
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
if (!response.ok) {
|
| 292 |
+
throw new Error(`Failed to delete ${modelName}`);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
|
| 296 |
+
toast(`Deleted ${modelName}`);
|
| 297 |
+
} catch (err) {
|
| 298 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
| 299 |
+
console.error(`Error deleting ${modelName}:`, errorMessage);
|
| 300 |
+
toast(`Failed to delete ${modelName}`);
|
| 301 |
+
}
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
// Update model details display
|
| 305 |
+
const ModelDetails = ({ model }: { model: OllamaModel }) => (
|
| 306 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
| 307 |
+
<div className="flex items-center gap-1">
|
| 308 |
+
<div className="i-ph:code text-purple-500" />
|
| 309 |
+
<span>{model.digest.substring(0, 7)}</span>
|
| 310 |
+
</div>
|
| 311 |
+
{model.details && (
|
| 312 |
+
<>
|
| 313 |
+
<div className="flex items-center gap-1">
|
| 314 |
+
<div className="i-ph:database text-purple-500" />
|
| 315 |
+
<span>{model.details.parameter_size}</span>
|
| 316 |
+
</div>
|
| 317 |
+
<div className="flex items-center gap-1">
|
| 318 |
+
<div className="i-ph:cube text-purple-500" />
|
| 319 |
+
<span>{model.details.quantization_level}</span>
|
| 320 |
+
</div>
|
| 321 |
+
</>
|
| 322 |
+
)}
|
| 323 |
+
</div>
|
| 324 |
+
);
|
| 325 |
+
|
| 326 |
+
// Update model actions to not use Tooltip
|
| 327 |
+
const ModelActions = ({
|
| 328 |
+
model,
|
| 329 |
+
onUpdate,
|
| 330 |
+
onDelete,
|
| 331 |
+
}: {
|
| 332 |
+
model: OllamaModel;
|
| 333 |
+
onUpdate: () => void;
|
| 334 |
+
onDelete: () => void;
|
| 335 |
+
}) => (
|
| 336 |
+
<div className="flex items-center gap-2">
|
| 337 |
+
<motion.button
|
| 338 |
+
onClick={onUpdate}
|
| 339 |
+
disabled={model.status === 'updating'}
|
| 340 |
+
className={classNames(
|
| 341 |
+
'rounded-lg p-2',
|
| 342 |
+
'bg-purple-500/10 text-purple-500',
|
| 343 |
+
'hover:bg-purple-500/20',
|
| 344 |
+
'transition-all duration-200',
|
| 345 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
| 346 |
+
)}
|
| 347 |
+
whileHover={{ scale: 1.05 }}
|
| 348 |
+
whileTap={{ scale: 0.95 }}
|
| 349 |
+
title="Update model"
|
| 350 |
+
>
|
| 351 |
+
{model.status === 'updating' ? (
|
| 352 |
+
<div className="flex items-center gap-2">
|
| 353 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 354 |
+
<span className="text-sm">Updating...</span>
|
| 355 |
+
</div>
|
| 356 |
+
) : (
|
| 357 |
+
<div className="i-ph:arrows-clockwise text-lg" />
|
| 358 |
+
)}
|
| 359 |
+
</motion.button>
|
| 360 |
+
<motion.button
|
| 361 |
+
onClick={onDelete}
|
| 362 |
+
disabled={model.status === 'updating'}
|
| 363 |
+
className={classNames(
|
| 364 |
+
'rounded-lg p-2',
|
| 365 |
+
'bg-red-500/10 text-red-500',
|
| 366 |
+
'hover:bg-red-500/20',
|
| 367 |
+
'transition-all duration-200',
|
| 368 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
| 369 |
+
)}
|
| 370 |
+
whileHover={{ scale: 1.05 }}
|
| 371 |
+
whileTap={{ scale: 0.95 }}
|
| 372 |
+
title="Delete model"
|
| 373 |
+
>
|
| 374 |
+
<div className="i-ph:trash text-lg" />
|
| 375 |
+
</motion.button>
|
| 376 |
+
</div>
|
| 377 |
+
);
|
| 378 |
+
|
| 379 |
+
return (
|
| 380 |
+
<div
|
| 381 |
+
className={classNames(
|
| 382 |
+
'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
|
| 383 |
+
'hover:bg-bolt-elements-background-depth-2',
|
| 384 |
+
'transition-all duration-200',
|
| 385 |
+
)}
|
| 386 |
+
role="region"
|
| 387 |
+
aria-label="Local Providers Configuration"
|
| 388 |
+
>
|
| 389 |
+
<motion.div
|
| 390 |
+
className="space-y-6"
|
| 391 |
+
initial={{ opacity: 0, y: 20 }}
|
| 392 |
+
animate={{ opacity: 1, y: 0 }}
|
| 393 |
+
transition={{ duration: 0.3 }}
|
| 394 |
+
>
|
| 395 |
+
{/* Header section */}
|
| 396 |
+
<div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
|
| 397 |
+
<div className="flex items-center gap-3">
|
| 398 |
+
<motion.div
|
| 399 |
+
className={classNames(
|
| 400 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
| 401 |
+
'bg-purple-500/10 text-purple-500',
|
| 402 |
+
)}
|
| 403 |
+
whileHover={{ scale: 1.05 }}
|
| 404 |
+
>
|
| 405 |
+
<BiChip className="w-6 h-6" />
|
| 406 |
+
</motion.div>
|
| 407 |
+
<div>
|
| 408 |
+
<div className="flex items-center gap-2">
|
| 409 |
+
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
|
| 410 |
+
</div>
|
| 411 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
|
| 415 |
+
<div className="flex items-center gap-2">
|
| 416 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
|
| 417 |
+
<Switch
|
| 418 |
+
checked={categoryEnabled}
|
| 419 |
+
onCheckedChange={handleToggleCategory}
|
| 420 |
+
aria-label="Toggle all local providers"
|
| 421 |
+
/>
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
|
| 425 |
+
{/* Ollama Section */}
|
| 426 |
+
{filteredProviders
|
| 427 |
+
.filter((provider) => provider.name === 'Ollama')
|
| 428 |
+
.map((provider) => (
|
| 429 |
+
<motion.div
|
| 430 |
+
key={provider.name}
|
| 431 |
+
className={classNames(
|
| 432 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
| 433 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 434 |
+
'transition-all duration-200 p-5',
|
| 435 |
+
'relative overflow-hidden group',
|
| 436 |
+
)}
|
| 437 |
+
initial={{ opacity: 0, y: 20 }}
|
| 438 |
+
animate={{ opacity: 1, y: 0 }}
|
| 439 |
+
whileHover={{ scale: 1.01 }}
|
| 440 |
+
>
|
| 441 |
+
{/* Provider Header */}
|
| 442 |
+
<div className="flex items-start justify-between gap-4">
|
| 443 |
+
<div className="flex items-start gap-4">
|
| 444 |
+
<motion.div
|
| 445 |
+
className={classNames(
|
| 446 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
| 447 |
+
'bg-bolt-elements-background-depth-3',
|
| 448 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
| 449 |
+
)}
|
| 450 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
| 451 |
+
>
|
| 452 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
| 453 |
+
className: 'w-7 h-7',
|
| 454 |
+
'aria-label': `${provider.name} icon`,
|
| 455 |
+
})}
|
| 456 |
+
</motion.div>
|
| 457 |
+
<div>
|
| 458 |
+
<div className="flex items-center gap-2">
|
| 459 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
| 460 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
|
| 461 |
+
</div>
|
| 462 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
| 463 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
| 464 |
+
</p>
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
<Switch
|
| 468 |
+
checked={provider.settings.enabled}
|
| 469 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
| 470 |
+
aria-label={`Toggle ${provider.name} provider`}
|
| 471 |
+
/>
|
| 472 |
+
</div>
|
| 473 |
+
|
| 474 |
+
{/* URL Configuration Section */}
|
| 475 |
+
<AnimatePresence>
|
| 476 |
+
{provider.settings.enabled && (
|
| 477 |
+
<motion.div
|
| 478 |
+
initial={{ opacity: 0, height: 0 }}
|
| 479 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 480 |
+
exit={{ opacity: 0, height: 0 }}
|
| 481 |
+
className="mt-4"
|
| 482 |
+
>
|
| 483 |
+
<div className="flex flex-col gap-2">
|
| 484 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
| 485 |
+
{editingProvider === provider.name ? (
|
| 486 |
+
<input
|
| 487 |
+
type="text"
|
| 488 |
+
defaultValue={provider.settings.baseUrl || OLLAMA_API_URL}
|
| 489 |
+
placeholder="Enter Ollama base URL"
|
| 490 |
+
className={classNames(
|
| 491 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 492 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 493 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 494 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 495 |
+
'transition-all duration-200',
|
| 496 |
+
)}
|
| 497 |
+
onKeyDown={(e) => {
|
| 498 |
+
if (e.key === 'Enter') {
|
| 499 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
| 500 |
+
} else if (e.key === 'Escape') {
|
| 501 |
+
setEditingProvider(null);
|
| 502 |
+
}
|
| 503 |
+
}}
|
| 504 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
| 505 |
+
autoFocus
|
| 506 |
+
/>
|
| 507 |
+
) : (
|
| 508 |
+
<div
|
| 509 |
+
onClick={() => setEditingProvider(provider.name)}
|
| 510 |
+
className={classNames(
|
| 511 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
| 512 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 513 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
| 514 |
+
'transition-all duration-200',
|
| 515 |
+
)}
|
| 516 |
+
>
|
| 517 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
| 518 |
+
<div className="i-ph:link text-sm" />
|
| 519 |
+
<span>{provider.settings.baseUrl || OLLAMA_API_URL}</span>
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
)}
|
| 523 |
+
</div>
|
| 524 |
+
</motion.div>
|
| 525 |
+
)}
|
| 526 |
+
</AnimatePresence>
|
| 527 |
+
|
| 528 |
+
{/* Ollama Models Section */}
|
| 529 |
+
{provider.settings.enabled && (
|
| 530 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
|
| 531 |
+
<div className="flex items-center justify-between">
|
| 532 |
+
<div className="flex items-center gap-2">
|
| 533 |
+
<div className="i-ph:cube-duotone text-purple-500" />
|
| 534 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
|
| 535 |
+
</div>
|
| 536 |
+
{isLoadingModels ? (
|
| 537 |
+
<div className="flex items-center gap-2">
|
| 538 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 539 |
+
<span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
|
| 540 |
+
</div>
|
| 541 |
+
) : (
|
| 542 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
| 543 |
+
{ollamaModels.length} models available
|
| 544 |
+
</span>
|
| 545 |
+
)}
|
| 546 |
+
</div>
|
| 547 |
+
|
| 548 |
+
<div className="space-y-3">
|
| 549 |
+
{isLoadingModels ? (
|
| 550 |
+
<div className="space-y-3">
|
| 551 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
| 552 |
+
<div
|
| 553 |
+
key={i}
|
| 554 |
+
className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
|
| 555 |
+
/>
|
| 556 |
+
))}
|
| 557 |
+
</div>
|
| 558 |
+
) : ollamaModels.length === 0 ? (
|
| 559 |
+
<div className="text-center py-8 text-bolt-elements-textSecondary">
|
| 560 |
+
<div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
|
| 561 |
+
<p>No models installed yet</p>
|
| 562 |
+
<p className="text-sm text-bolt-elements-textTertiary px-1">
|
| 563 |
+
Browse models at{' '}
|
| 564 |
+
<a
|
| 565 |
+
href="https://ollama.com/library"
|
| 566 |
+
target="_blank"
|
| 567 |
+
rel="noopener noreferrer"
|
| 568 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-0.5 text-base font-medium"
|
| 569 |
+
>
|
| 570 |
+
ollama.com/library
|
| 571 |
+
<div className="i-ph:arrow-square-out text-xs" />
|
| 572 |
+
</a>{' '}
|
| 573 |
+
and copy model names to install
|
| 574 |
+
</p>
|
| 575 |
+
</div>
|
| 576 |
+
) : (
|
| 577 |
+
ollamaModels.map((model) => (
|
| 578 |
+
<motion.div
|
| 579 |
+
key={model.name}
|
| 580 |
+
className={classNames(
|
| 581 |
+
'p-4 rounded-xl',
|
| 582 |
+
'bg-bolt-elements-background-depth-3',
|
| 583 |
+
'hover:bg-bolt-elements-background-depth-4',
|
| 584 |
+
'transition-all duration-200',
|
| 585 |
+
)}
|
| 586 |
+
whileHover={{ scale: 1.01 }}
|
| 587 |
+
>
|
| 588 |
+
<div className="flex items-center justify-between">
|
| 589 |
+
<div className="space-y-2">
|
| 590 |
+
<div className="flex items-center gap-2">
|
| 591 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
|
| 592 |
+
<ModelStatusBadge status={model.status} />
|
| 593 |
+
</div>
|
| 594 |
+
<ModelDetails model={model} />
|
| 595 |
+
</div>
|
| 596 |
+
<ModelActions
|
| 597 |
+
model={model}
|
| 598 |
+
onUpdate={() => handleUpdateOllamaModel(model.name)}
|
| 599 |
+
onDelete={() => {
|
| 600 |
+
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
|
| 601 |
+
handleDeleteOllamaModel(model.name);
|
| 602 |
+
}
|
| 603 |
+
}}
|
| 604 |
+
/>
|
| 605 |
+
</div>
|
| 606 |
+
{model.progress && (
|
| 607 |
+
<div className="mt-3">
|
| 608 |
+
<Progress
|
| 609 |
+
value={Math.round((model.progress.current / model.progress.total) * 100)}
|
| 610 |
+
className="h-1"
|
| 611 |
+
/>
|
| 612 |
+
<div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
|
| 613 |
+
<span>{model.progress.status}</span>
|
| 614 |
+
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
)}
|
| 618 |
+
</motion.div>
|
| 619 |
+
))
|
| 620 |
+
)}
|
| 621 |
+
</div>
|
| 622 |
+
|
| 623 |
+
{/* Model Installation Section */}
|
| 624 |
+
<OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
|
| 625 |
+
</motion.div>
|
| 626 |
+
)}
|
| 627 |
+
</motion.div>
|
| 628 |
+
))}
|
| 629 |
+
|
| 630 |
+
{/* Other Providers Section */}
|
| 631 |
+
<div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
|
| 632 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
|
| 633 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 634 |
+
{filteredProviders
|
| 635 |
+
.filter((provider) => provider.name !== 'Ollama')
|
| 636 |
+
.map((provider, index) => (
|
| 637 |
+
<motion.div
|
| 638 |
+
key={provider.name}
|
| 639 |
+
className={classNames(
|
| 640 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
| 641 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 642 |
+
'transition-all duration-200 p-5',
|
| 643 |
+
'relative overflow-hidden group',
|
| 644 |
+
)}
|
| 645 |
+
initial={{ opacity: 0, y: 20 }}
|
| 646 |
+
animate={{ opacity: 1, y: 0 }}
|
| 647 |
+
transition={{ delay: index * 0.1 }}
|
| 648 |
+
whileHover={{ scale: 1.01 }}
|
| 649 |
+
>
|
| 650 |
+
{/* Provider Header */}
|
| 651 |
+
<div className="flex items-start justify-between gap-4">
|
| 652 |
+
<div className="flex items-start gap-4">
|
| 653 |
+
<motion.div
|
| 654 |
+
className={classNames(
|
| 655 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
| 656 |
+
'bg-bolt-elements-background-depth-3',
|
| 657 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
| 658 |
+
)}
|
| 659 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
| 660 |
+
>
|
| 661 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
| 662 |
+
className: 'w-7 h-7',
|
| 663 |
+
'aria-label': `${provider.name} icon`,
|
| 664 |
+
})}
|
| 665 |
+
</motion.div>
|
| 666 |
+
<div>
|
| 667 |
+
<div className="flex items-center gap-2">
|
| 668 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
| 669 |
+
<div className="flex gap-1">
|
| 670 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
|
| 671 |
+
Local
|
| 672 |
+
</span>
|
| 673 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
| 674 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
|
| 675 |
+
Configurable
|
| 676 |
+
</span>
|
| 677 |
+
)}
|
| 678 |
+
</div>
|
| 679 |
+
</div>
|
| 680 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
| 681 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
| 682 |
+
</p>
|
| 683 |
+
</div>
|
| 684 |
+
</div>
|
| 685 |
+
<Switch
|
| 686 |
+
checked={provider.settings.enabled}
|
| 687 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
| 688 |
+
aria-label={`Toggle ${provider.name} provider`}
|
| 689 |
+
/>
|
| 690 |
+
</div>
|
| 691 |
+
|
| 692 |
+
{/* URL Configuration Section */}
|
| 693 |
+
<AnimatePresence>
|
| 694 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
| 695 |
+
<motion.div
|
| 696 |
+
initial={{ opacity: 0, height: 0 }}
|
| 697 |
+
animate={{ opacity: 1, height: 'auto' }}
|
| 698 |
+
exit={{ opacity: 0, height: 0 }}
|
| 699 |
+
className="mt-4"
|
| 700 |
+
>
|
| 701 |
+
<div className="flex flex-col gap-2">
|
| 702 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
| 703 |
+
{editingProvider === provider.name ? (
|
| 704 |
+
<input
|
| 705 |
+
type="text"
|
| 706 |
+
defaultValue={provider.settings.baseUrl}
|
| 707 |
+
placeholder={`Enter ${provider.name} base URL`}
|
| 708 |
+
className={classNames(
|
| 709 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 710 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 711 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 712 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 713 |
+
'transition-all duration-200',
|
| 714 |
+
)}
|
| 715 |
+
onKeyDown={(e) => {
|
| 716 |
+
if (e.key === 'Enter') {
|
| 717 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
| 718 |
+
} else if (e.key === 'Escape') {
|
| 719 |
+
setEditingProvider(null);
|
| 720 |
+
}
|
| 721 |
+
}}
|
| 722 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
| 723 |
+
autoFocus
|
| 724 |
+
/>
|
| 725 |
+
) : (
|
| 726 |
+
<div
|
| 727 |
+
onClick={() => setEditingProvider(provider.name)}
|
| 728 |
+
className={classNames(
|
| 729 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
| 730 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 731 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
| 732 |
+
'transition-all duration-200',
|
| 733 |
+
)}
|
| 734 |
+
>
|
| 735 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
| 736 |
+
<div className="i-ph:link text-sm" />
|
| 737 |
+
<span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
|
| 738 |
+
</div>
|
| 739 |
+
</div>
|
| 740 |
+
)}
|
| 741 |
+
</div>
|
| 742 |
+
</motion.div>
|
| 743 |
+
)}
|
| 744 |
+
</AnimatePresence>
|
| 745 |
+
</motion.div>
|
| 746 |
+
))}
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
</motion.div>
|
| 750 |
+
</div>
|
| 751 |
+
);
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
// Helper component for model status badge
|
| 755 |
+
function ModelStatusBadge({ status }: { status?: string }) {
|
| 756 |
+
if (!status || status === 'idle') {
|
| 757 |
+
return null;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
const statusConfig = {
|
| 761 |
+
updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
|
| 762 |
+
updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
|
| 763 |
+
error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
|
| 764 |
+
};
|
| 765 |
+
|
| 766 |
+
const config = statusConfig[status as keyof typeof statusConfig];
|
| 767 |
+
|
| 768 |
+
if (!config) {
|
| 769 |
+
return null;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
return (
|
| 773 |
+
<span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
|
| 774 |
+
{config.label}
|
| 775 |
+
</span>
|
| 776 |
+
);
|
| 777 |
+
}
|
app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { Progress } from '~/components/ui/Progress';
|
| 5 |
+
import { useToast } from '~/components/ui/use-toast';
|
| 6 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 7 |
+
|
| 8 |
+
interface OllamaModelInstallerProps {
|
| 9 |
+
onModelInstalled: () => void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface InstallProgress {
|
| 13 |
+
status: string;
|
| 14 |
+
progress: number;
|
| 15 |
+
downloadedSize?: string;
|
| 16 |
+
totalSize?: string;
|
| 17 |
+
speed?: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface ModelInfo {
|
| 21 |
+
name: string;
|
| 22 |
+
desc: string;
|
| 23 |
+
size: string;
|
| 24 |
+
tags: string[];
|
| 25 |
+
installedVersion?: string;
|
| 26 |
+
latestVersion?: string;
|
| 27 |
+
needsUpdate?: boolean;
|
| 28 |
+
status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
|
| 29 |
+
details?: {
|
| 30 |
+
family: string;
|
| 31 |
+
parameter_size: string;
|
| 32 |
+
quantization_level: string;
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const POPULAR_MODELS: ModelInfo[] = [
|
| 37 |
+
{
|
| 38 |
+
name: 'deepseek-coder:6.7b',
|
| 39 |
+
desc: "DeepSeek's code generation model",
|
| 40 |
+
size: '4.1GB',
|
| 41 |
+
tags: ['coding', 'popular'],
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
name: 'llama2:7b',
|
| 45 |
+
desc: "Meta's Llama 2 (7B parameters)",
|
| 46 |
+
size: '3.8GB',
|
| 47 |
+
tags: ['general', 'popular'],
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
name: 'mistral:7b',
|
| 51 |
+
desc: "Mistral's 7B model",
|
| 52 |
+
size: '4.1GB',
|
| 53 |
+
tags: ['general', 'popular'],
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
name: 'gemma:7b',
|
| 57 |
+
desc: "Google's Gemma model",
|
| 58 |
+
size: '4.0GB',
|
| 59 |
+
tags: ['general', 'new'],
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
name: 'codellama:7b',
|
| 63 |
+
desc: "Meta's Code Llama model",
|
| 64 |
+
size: '4.1GB',
|
| 65 |
+
tags: ['coding', 'popular'],
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
name: 'neural-chat:7b',
|
| 69 |
+
desc: "Intel's Neural Chat model",
|
| 70 |
+
size: '4.1GB',
|
| 71 |
+
tags: ['chat', 'popular'],
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
name: 'phi:latest',
|
| 75 |
+
desc: "Microsoft's Phi-2 model",
|
| 76 |
+
size: '2.7GB',
|
| 77 |
+
tags: ['small', 'fast'],
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
name: 'qwen:7b',
|
| 81 |
+
desc: "Alibaba's Qwen model",
|
| 82 |
+
size: '4.1GB',
|
| 83 |
+
tags: ['general'],
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
name: 'solar:10.7b',
|
| 87 |
+
desc: "Upstage's Solar model",
|
| 88 |
+
size: '6.1GB',
|
| 89 |
+
tags: ['large', 'powerful'],
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
name: 'openchat:7b',
|
| 93 |
+
desc: 'Open-source chat model',
|
| 94 |
+
size: '4.1GB',
|
| 95 |
+
tags: ['chat', 'popular'],
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
name: 'dolphin-phi:2.7b',
|
| 99 |
+
desc: 'Lightweight chat model',
|
| 100 |
+
size: '1.6GB',
|
| 101 |
+
tags: ['small', 'fast'],
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
name: 'stable-code:3b',
|
| 105 |
+
desc: 'Lightweight coding model',
|
| 106 |
+
size: '1.8GB',
|
| 107 |
+
tags: ['coding', 'small'],
|
| 108 |
+
},
|
| 109 |
+
];
|
| 110 |
+
|
| 111 |
+
function formatBytes(bytes: number): string {
|
| 112 |
+
if (bytes === 0) {
|
| 113 |
+
return '0 B';
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const k = 1024;
|
| 117 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
| 118 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 119 |
+
|
| 120 |
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function formatSpeed(bytesPerSecond: number): string {
|
| 124 |
+
return `${formatBytes(bytesPerSecond)}/s`;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Add Ollama Icon SVG component
|
| 128 |
+
function OllamaIcon({ className }: { className?: string }) {
|
| 129 |
+
return (
|
| 130 |
+
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
|
| 131 |
+
<path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
|
| 132 |
+
</svg>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
|
| 137 |
+
const [modelString, setModelString] = useState('');
|
| 138 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 139 |
+
const [isInstalling, setIsInstalling] = useState(false);
|
| 140 |
+
const [isChecking, setIsChecking] = useState(false);
|
| 141 |
+
const [installProgress, setInstallProgress] = useState<InstallProgress | null>(null);
|
| 142 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
| 143 |
+
const [models, setModels] = useState<ModelInfo[]>(POPULAR_MODELS);
|
| 144 |
+
const { toast } = useToast();
|
| 145 |
+
const { providers } = useSettings();
|
| 146 |
+
|
| 147 |
+
// Get base URL from provider settings
|
| 148 |
+
const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434';
|
| 149 |
+
|
| 150 |
+
// Function to check installed models and their versions
|
| 151 |
+
const checkInstalledModels = async () => {
|
| 152 |
+
try {
|
| 153 |
+
const response = await fetch(`${baseUrl}/api/tags`, {
|
| 154 |
+
method: 'GET',
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
if (!response.ok) {
|
| 158 |
+
throw new Error('Failed to fetch installed models');
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
|
| 162 |
+
const installedModels = data.models || [];
|
| 163 |
+
|
| 164 |
+
// Update models with installed versions
|
| 165 |
+
setModels((prevModels) =>
|
| 166 |
+
prevModels.map((model) => {
|
| 167 |
+
const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
|
| 168 |
+
|
| 169 |
+
if (installed) {
|
| 170 |
+
return {
|
| 171 |
+
...model,
|
| 172 |
+
installedVersion: installed.digest.substring(0, 8),
|
| 173 |
+
needsUpdate: installed.digest !== installed.latest,
|
| 174 |
+
latestVersion: installed.latest?.substring(0, 8),
|
| 175 |
+
};
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return model;
|
| 179 |
+
}),
|
| 180 |
+
);
|
| 181 |
+
} catch (error) {
|
| 182 |
+
console.error('Error checking installed models:', error);
|
| 183 |
+
}
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
// Check installed models on mount and after installation
|
| 187 |
+
useEffect(() => {
|
| 188 |
+
checkInstalledModels();
|
| 189 |
+
}, [baseUrl]);
|
| 190 |
+
|
| 191 |
+
const handleCheckUpdates = async () => {
|
| 192 |
+
setIsChecking(true);
|
| 193 |
+
|
| 194 |
+
try {
|
| 195 |
+
await checkInstalledModels();
|
| 196 |
+
toast('Model versions checked');
|
| 197 |
+
} catch (err) {
|
| 198 |
+
console.error('Failed to check model versions:', err);
|
| 199 |
+
toast('Failed to check model versions');
|
| 200 |
+
} finally {
|
| 201 |
+
setIsChecking(false);
|
| 202 |
+
}
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const filteredModels = models.filter((model) => {
|
| 206 |
+
const matchesSearch =
|
| 207 |
+
searchQuery === '' ||
|
| 208 |
+
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 209 |
+
model.desc.toLowerCase().includes(searchQuery.toLowerCase());
|
| 210 |
+
const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
|
| 211 |
+
|
| 212 |
+
return matchesSearch && matchesTags;
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
const handleInstallModel = async (modelToInstall: string) => {
|
| 216 |
+
if (!modelToInstall) {
|
| 217 |
+
return;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
try {
|
| 221 |
+
setIsInstalling(true);
|
| 222 |
+
setInstallProgress({
|
| 223 |
+
status: 'Starting download...',
|
| 224 |
+
progress: 0,
|
| 225 |
+
downloadedSize: '0 B',
|
| 226 |
+
totalSize: 'Calculating...',
|
| 227 |
+
speed: '0 B/s',
|
| 228 |
+
});
|
| 229 |
+
setModelString('');
|
| 230 |
+
setSearchQuery('');
|
| 231 |
+
|
| 232 |
+
const response = await fetch(`${baseUrl}/api/pull`, {
|
| 233 |
+
method: 'POST',
|
| 234 |
+
headers: {
|
| 235 |
+
'Content-Type': 'application/json',
|
| 236 |
+
},
|
| 237 |
+
body: JSON.stringify({ name: modelToInstall }),
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
if (!response.ok) {
|
| 241 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
const reader = response.body?.getReader();
|
| 245 |
+
|
| 246 |
+
if (!reader) {
|
| 247 |
+
throw new Error('Failed to get response reader');
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
let lastTime = Date.now();
|
| 251 |
+
let lastBytes = 0;
|
| 252 |
+
|
| 253 |
+
while (true) {
|
| 254 |
+
const { done, value } = await reader.read();
|
| 255 |
+
|
| 256 |
+
if (done) {
|
| 257 |
+
break;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
const text = new TextDecoder().decode(value);
|
| 261 |
+
const lines = text.split('\n').filter(Boolean);
|
| 262 |
+
|
| 263 |
+
for (const line of lines) {
|
| 264 |
+
try {
|
| 265 |
+
const data = JSON.parse(line);
|
| 266 |
+
|
| 267 |
+
if ('status' in data) {
|
| 268 |
+
const currentTime = Date.now();
|
| 269 |
+
const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
|
| 270 |
+
const bytesDiff = (data.completed || 0) - lastBytes;
|
| 271 |
+
const speed = bytesDiff / timeDiff;
|
| 272 |
+
|
| 273 |
+
setInstallProgress({
|
| 274 |
+
status: data.status,
|
| 275 |
+
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
| 276 |
+
downloadedSize: formatBytes(data.completed || 0),
|
| 277 |
+
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
| 278 |
+
speed: formatSpeed(speed),
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
lastTime = currentTime;
|
| 282 |
+
lastBytes = data.completed || 0;
|
| 283 |
+
}
|
| 284 |
+
} catch (err) {
|
| 285 |
+
console.error('Error parsing progress:', err);
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
|
| 291 |
+
|
| 292 |
+
// Ensure we call onModelInstalled after successful installation
|
| 293 |
+
setTimeout(() => {
|
| 294 |
+
onModelInstalled();
|
| 295 |
+
}, 1000);
|
| 296 |
+
} catch (err) {
|
| 297 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
| 298 |
+
console.error(`Error installing ${modelToInstall}:`, errorMessage);
|
| 299 |
+
toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
|
| 300 |
+
} finally {
|
| 301 |
+
setIsInstalling(false);
|
| 302 |
+
setInstallProgress(null);
|
| 303 |
+
}
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
const handleUpdateModel = async (modelToUpdate: string) => {
|
| 307 |
+
try {
|
| 308 |
+
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
|
| 309 |
+
|
| 310 |
+
const response = await fetch(`${baseUrl}/api/pull`, {
|
| 311 |
+
method: 'POST',
|
| 312 |
+
headers: {
|
| 313 |
+
'Content-Type': 'application/json',
|
| 314 |
+
},
|
| 315 |
+
body: JSON.stringify({ name: modelToUpdate }),
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
if (!response.ok) {
|
| 319 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
const reader = response.body?.getReader();
|
| 323 |
+
|
| 324 |
+
if (!reader) {
|
| 325 |
+
throw new Error('Failed to get response reader');
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
let lastTime = Date.now();
|
| 329 |
+
let lastBytes = 0;
|
| 330 |
+
|
| 331 |
+
while (true) {
|
| 332 |
+
const { done, value } = await reader.read();
|
| 333 |
+
|
| 334 |
+
if (done) {
|
| 335 |
+
break;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
const text = new TextDecoder().decode(value);
|
| 339 |
+
const lines = text.split('\n').filter(Boolean);
|
| 340 |
+
|
| 341 |
+
for (const line of lines) {
|
| 342 |
+
try {
|
| 343 |
+
const data = JSON.parse(line);
|
| 344 |
+
|
| 345 |
+
if ('status' in data) {
|
| 346 |
+
const currentTime = Date.now();
|
| 347 |
+
const timeDiff = (currentTime - lastTime) / 1000;
|
| 348 |
+
const bytesDiff = (data.completed || 0) - lastBytes;
|
| 349 |
+
const speed = bytesDiff / timeDiff;
|
| 350 |
+
|
| 351 |
+
setInstallProgress({
|
| 352 |
+
status: data.status,
|
| 353 |
+
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
| 354 |
+
downloadedSize: formatBytes(data.completed || 0),
|
| 355 |
+
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
| 356 |
+
speed: formatSpeed(speed),
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
lastTime = currentTime;
|
| 360 |
+
lastBytes = data.completed || 0;
|
| 361 |
+
}
|
| 362 |
+
} catch (err) {
|
| 363 |
+
console.error('Error parsing progress:', err);
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
toast('Successfully updated ' + modelToUpdate);
|
| 369 |
+
|
| 370 |
+
// Refresh model list after update
|
| 371 |
+
await checkInstalledModels();
|
| 372 |
+
} catch (err) {
|
| 373 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
| 374 |
+
console.error(`Error updating ${modelToUpdate}:`, errorMessage);
|
| 375 |
+
toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
|
| 376 |
+
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
|
| 377 |
+
} finally {
|
| 378 |
+
setInstallProgress(null);
|
| 379 |
+
}
|
| 380 |
+
};
|
| 381 |
+
|
| 382 |
+
const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
|
| 383 |
+
|
| 384 |
+
return (
|
| 385 |
+
<div className="space-y-6">
|
| 386 |
+
<div className="flex items-center justify-between pt-6">
|
| 387 |
+
<div className="flex items-center gap-3">
|
| 388 |
+
<OllamaIcon className="w-8 h-8 text-purple-500" />
|
| 389 |
+
<div>
|
| 390 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
|
| 391 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
<motion.button
|
| 395 |
+
onClick={handleCheckUpdates}
|
| 396 |
+
disabled={isChecking}
|
| 397 |
+
className={classNames(
|
| 398 |
+
'px-4 py-2 rounded-lg',
|
| 399 |
+
'bg-purple-500/10 text-purple-500',
|
| 400 |
+
'hover:bg-purple-500/20',
|
| 401 |
+
'transition-all duration-200',
|
| 402 |
+
'flex items-center gap-2',
|
| 403 |
+
)}
|
| 404 |
+
whileHover={{ scale: 1.02 }}
|
| 405 |
+
whileTap={{ scale: 0.98 }}
|
| 406 |
+
>
|
| 407 |
+
{isChecking ? (
|
| 408 |
+
<div className="i-ph:spinner-gap-bold animate-spin" />
|
| 409 |
+
) : (
|
| 410 |
+
<div className="i-ph:arrows-clockwise" />
|
| 411 |
+
)}
|
| 412 |
+
Check Updates
|
| 413 |
+
</motion.button>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
<div className="flex gap-4">
|
| 417 |
+
<div className="flex-1">
|
| 418 |
+
<div className="space-y-1">
|
| 419 |
+
<input
|
| 420 |
+
type="text"
|
| 421 |
+
className={classNames(
|
| 422 |
+
'w-full px-4 py-3 rounded-xl',
|
| 423 |
+
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
| 424 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 425 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 426 |
+
'transition-all duration-200',
|
| 427 |
+
)}
|
| 428 |
+
placeholder="Search models or enter custom model name..."
|
| 429 |
+
value={searchQuery || modelString}
|
| 430 |
+
onChange={(e) => {
|
| 431 |
+
const value = e.target.value;
|
| 432 |
+
setSearchQuery(value);
|
| 433 |
+
setModelString(value);
|
| 434 |
+
}}
|
| 435 |
+
disabled={isInstalling}
|
| 436 |
+
/>
|
| 437 |
+
<p className="text-sm text-bolt-elements-textSecondary px-1">
|
| 438 |
+
Browse models at{' '}
|
| 439 |
+
<a
|
| 440 |
+
href="https://ollama.com/library"
|
| 441 |
+
target="_blank"
|
| 442 |
+
rel="noopener noreferrer"
|
| 443 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-1 text-base font-medium"
|
| 444 |
+
>
|
| 445 |
+
ollama.com/library
|
| 446 |
+
<div className="i-ph:arrow-square-out text-sm" />
|
| 447 |
+
</a>{' '}
|
| 448 |
+
and copy model names to install
|
| 449 |
+
</p>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
<motion.button
|
| 453 |
+
onClick={() => handleInstallModel(modelString)}
|
| 454 |
+
disabled={!modelString || isInstalling}
|
| 455 |
+
className={classNames(
|
| 456 |
+
'rounded-lg px-4 py-2',
|
| 457 |
+
'bg-purple-500 text-white text-sm',
|
| 458 |
+
'hover:bg-purple-600',
|
| 459 |
+
'transition-all duration-200',
|
| 460 |
+
'flex items-center gap-2',
|
| 461 |
+
{ 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
|
| 462 |
+
)}
|
| 463 |
+
whileHover={{ scale: 1.02 }}
|
| 464 |
+
whileTap={{ scale: 0.98 }}
|
| 465 |
+
>
|
| 466 |
+
{isInstalling ? (
|
| 467 |
+
<div className="flex items-center gap-2">
|
| 468 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
| 469 |
+
<span>Installing...</span>
|
| 470 |
+
</div>
|
| 471 |
+
) : (
|
| 472 |
+
<div className="flex items-center gap-2">
|
| 473 |
+
<OllamaIcon className="w-4 h-4" />
|
| 474 |
+
<span>Install Model</span>
|
| 475 |
+
</div>
|
| 476 |
+
)}
|
| 477 |
+
</motion.button>
|
| 478 |
+
</div>
|
| 479 |
+
|
| 480 |
+
<div className="flex flex-wrap gap-2">
|
| 481 |
+
{allTags.map((tag) => (
|
| 482 |
+
<button
|
| 483 |
+
key={tag}
|
| 484 |
+
onClick={() => {
|
| 485 |
+
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
| 486 |
+
}}
|
| 487 |
+
className={classNames(
|
| 488 |
+
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
|
| 489 |
+
selectedTags.includes(tag)
|
| 490 |
+
? 'bg-purple-500 text-white'
|
| 491 |
+
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
|
| 492 |
+
)}
|
| 493 |
+
>
|
| 494 |
+
{tag}
|
| 495 |
+
</button>
|
| 496 |
+
))}
|
| 497 |
+
</div>
|
| 498 |
+
|
| 499 |
+
<div className="grid grid-cols-1 gap-2">
|
| 500 |
+
{filteredModels.map((model) => (
|
| 501 |
+
<motion.div
|
| 502 |
+
key={model.name}
|
| 503 |
+
className={classNames(
|
| 504 |
+
'flex items-start gap-2 p-3 rounded-lg',
|
| 505 |
+
'bg-bolt-elements-background-depth-3',
|
| 506 |
+
'hover:bg-bolt-elements-background-depth-4',
|
| 507 |
+
'transition-all duration-200',
|
| 508 |
+
'relative group',
|
| 509 |
+
)}
|
| 510 |
+
>
|
| 511 |
+
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
| 512 |
+
<div className="flex-1 space-y-1.5">
|
| 513 |
+
<div className="flex items-start justify-between">
|
| 514 |
+
<div>
|
| 515 |
+
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
|
| 516 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
|
| 517 |
+
</div>
|
| 518 |
+
<div className="text-right">
|
| 519 |
+
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
|
| 520 |
+
{model.installedVersion && (
|
| 521 |
+
<div className="mt-0.5 flex flex-col items-end gap-0.5">
|
| 522 |
+
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
|
| 523 |
+
{model.needsUpdate && model.latestVersion && (
|
| 524 |
+
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
|
| 525 |
+
)}
|
| 526 |
+
</div>
|
| 527 |
+
)}
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
<div className="flex items-center justify-between">
|
| 531 |
+
<div className="flex flex-wrap gap-1">
|
| 532 |
+
{model.tags.map((tag) => (
|
| 533 |
+
<span
|
| 534 |
+
key={tag}
|
| 535 |
+
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
|
| 536 |
+
>
|
| 537 |
+
{tag}
|
| 538 |
+
</span>
|
| 539 |
+
))}
|
| 540 |
+
</div>
|
| 541 |
+
<div className="flex gap-2">
|
| 542 |
+
{model.installedVersion ? (
|
| 543 |
+
model.needsUpdate ? (
|
| 544 |
+
<motion.button
|
| 545 |
+
onClick={() => handleUpdateModel(model.name)}
|
| 546 |
+
className={classNames(
|
| 547 |
+
'px-2 py-0.5 rounded-lg text-xs',
|
| 548 |
+
'bg-purple-500 text-white',
|
| 549 |
+
'hover:bg-purple-600',
|
| 550 |
+
'transition-all duration-200',
|
| 551 |
+
'flex items-center gap-1',
|
| 552 |
+
)}
|
| 553 |
+
whileHover={{ scale: 1.02 }}
|
| 554 |
+
whileTap={{ scale: 0.98 }}
|
| 555 |
+
>
|
| 556 |
+
<div className="i-ph:arrows-clockwise text-xs" />
|
| 557 |
+
Update
|
| 558 |
+
</motion.button>
|
| 559 |
+
) : (
|
| 560 |
+
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
|
| 561 |
+
)
|
| 562 |
+
) : (
|
| 563 |
+
<motion.button
|
| 564 |
+
onClick={() => handleInstallModel(model.name)}
|
| 565 |
+
className={classNames(
|
| 566 |
+
'px-2 py-0.5 rounded-lg text-xs',
|
| 567 |
+
'bg-purple-500 text-white',
|
| 568 |
+
'hover:bg-purple-600',
|
| 569 |
+
'transition-all duration-200',
|
| 570 |
+
'flex items-center gap-1',
|
| 571 |
+
)}
|
| 572 |
+
whileHover={{ scale: 1.02 }}
|
| 573 |
+
whileTap={{ scale: 0.98 }}
|
| 574 |
+
>
|
| 575 |
+
<div className="i-ph:download text-xs" />
|
| 576 |
+
Install
|
| 577 |
+
</motion.button>
|
| 578 |
+
)}
|
| 579 |
+
</div>
|
| 580 |
+
</div>
|
| 581 |
+
</div>
|
| 582 |
+
</motion.div>
|
| 583 |
+
))}
|
| 584 |
+
</div>
|
| 585 |
+
|
| 586 |
+
{installProgress && (
|
| 587 |
+
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
| 588 |
+
<div className="flex justify-between text-sm">
|
| 589 |
+
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
|
| 590 |
+
<div className="flex items-center gap-4">
|
| 591 |
+
<span className="text-bolt-elements-textTertiary">
|
| 592 |
+
{installProgress.downloadedSize} / {installProgress.totalSize}
|
| 593 |
+
</span>
|
| 594 |
+
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
|
| 595 |
+
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
|
| 596 |
+
</div>
|
| 597 |
+
</div>
|
| 598 |
+
<Progress value={installProgress.progress} className="h-1" />
|
| 599 |
+
</motion.div>
|
| 600 |
+
)}
|
| 601 |
+
</div>
|
| 602 |
+
);
|
| 603 |
+
}
|
app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import type { ServiceStatus } from './types';
|
| 3 |
+
import { ProviderStatusCheckerFactory } from './provider-factory';
|
| 4 |
+
|
| 5 |
+
export default function ServiceStatusTab() {
|
| 6 |
+
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
| 7 |
+
const [loading, setLoading] = useState(true);
|
| 8 |
+
const [error, setError] = useState<string | null>(null);
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const checkAllProviders = async () => {
|
| 12 |
+
try {
|
| 13 |
+
setLoading(true);
|
| 14 |
+
setError(null);
|
| 15 |
+
|
| 16 |
+
const providers = ProviderStatusCheckerFactory.getProviderNames();
|
| 17 |
+
const statuses: ServiceStatus[] = [];
|
| 18 |
+
|
| 19 |
+
for (const provider of providers) {
|
| 20 |
+
try {
|
| 21 |
+
const checker = ProviderStatusCheckerFactory.getChecker(provider);
|
| 22 |
+
const result = await checker.checkStatus();
|
| 23 |
+
|
| 24 |
+
statuses.push({
|
| 25 |
+
provider,
|
| 26 |
+
...result,
|
| 27 |
+
lastChecked: new Date().toISOString(),
|
| 28 |
+
});
|
| 29 |
+
} catch (err) {
|
| 30 |
+
console.error(`Error checking ${provider} status:`, err);
|
| 31 |
+
statuses.push({
|
| 32 |
+
provider,
|
| 33 |
+
status: 'degraded',
|
| 34 |
+
message: 'Unable to check service status',
|
| 35 |
+
incidents: ['Error checking service status'],
|
| 36 |
+
lastChecked: new Date().toISOString(),
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
setServiceStatuses(statuses);
|
| 42 |
+
} catch (err) {
|
| 43 |
+
console.error('Error checking provider statuses:', err);
|
| 44 |
+
setError('Failed to check service statuses');
|
| 45 |
+
} finally {
|
| 46 |
+
setLoading(false);
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
checkAllProviders();
|
| 51 |
+
|
| 52 |
+
// Set up periodic checks every 5 minutes
|
| 53 |
+
const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
|
| 54 |
+
|
| 55 |
+
return () => clearInterval(interval);
|
| 56 |
+
}, []);
|
| 57 |
+
|
| 58 |
+
const getStatusColor = (status: ServiceStatus['status']) => {
|
| 59 |
+
switch (status) {
|
| 60 |
+
case 'operational':
|
| 61 |
+
return 'text-green-500 dark:text-green-400';
|
| 62 |
+
case 'degraded':
|
| 63 |
+
return 'text-yellow-500 dark:text-yellow-400';
|
| 64 |
+
case 'down':
|
| 65 |
+
return 'text-red-500 dark:text-red-400';
|
| 66 |
+
default:
|
| 67 |
+
return 'text-gray-500 dark:text-gray-400';
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const getStatusIcon = (status: ServiceStatus['status']) => {
|
| 72 |
+
switch (status) {
|
| 73 |
+
case 'operational':
|
| 74 |
+
return 'i-ph:check-circle';
|
| 75 |
+
case 'degraded':
|
| 76 |
+
return 'i-ph:warning';
|
| 77 |
+
case 'down':
|
| 78 |
+
return 'i-ph:x-circle';
|
| 79 |
+
default:
|
| 80 |
+
return 'i-ph:question';
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
if (loading) {
|
| 85 |
+
return (
|
| 86 |
+
<div className="flex items-center justify-center h-full">
|
| 87 |
+
<div className="animate-spin i-ph:circle-notch w-8 h-8 text-purple-500" />
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (error) {
|
| 93 |
+
return (
|
| 94 |
+
<div className="flex flex-col items-center justify-center h-full text-red-500 dark:text-red-400">
|
| 95 |
+
<div className="i-ph:warning w-8 h-8 mb-2" />
|
| 96 |
+
<p>{error}</p>
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
return (
|
| 102 |
+
<div className="space-y-6">
|
| 103 |
+
<div className="grid grid-cols-1 gap-4">
|
| 104 |
+
{serviceStatuses.map((service) => (
|
| 105 |
+
<div
|
| 106 |
+
key={service.provider}
|
| 107 |
+
className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
| 108 |
+
>
|
| 109 |
+
<div className="flex items-center justify-between mb-2">
|
| 110 |
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{service.provider}</h3>
|
| 111 |
+
<div className={`flex items-center ${getStatusColor(service.status)}`}>
|
| 112 |
+
<div className={`${getStatusIcon(service.status)} w-5 h-5 mr-2`} />
|
| 113 |
+
<span className="capitalize">{service.status}</span>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
<p className="text-gray-600 dark:text-gray-300 mb-2">{service.message}</p>
|
| 117 |
+
{service.incidents && service.incidents.length > 0 && (
|
| 118 |
+
<div className="mt-2">
|
| 119 |
+
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Recent Incidents:</h4>
|
| 120 |
+
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
| 121 |
+
{service.incidents.map((incident, index) => (
|
| 122 |
+
<li key={index}>{incident}</li>
|
| 123 |
+
))}
|
| 124 |
+
</ul>
|
| 125 |
+
</div>
|
| 126 |
+
)}
|
| 127 |
+
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
| 128 |
+
Last checked: {new Date(service.lastChecked).toLocaleString()}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
))}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
}
|
app/components/@settings/tabs/providers/service-status/base-provider.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
|
| 2 |
+
|
| 3 |
+
export abstract class BaseProviderChecker {
|
| 4 |
+
protected config: ProviderConfig;
|
| 5 |
+
|
| 6 |
+
constructor(config: ProviderConfig) {
|
| 7 |
+
this.config = config;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
protected async checkApiEndpoint(
|
| 11 |
+
url: string,
|
| 12 |
+
headers?: Record<string, string>,
|
| 13 |
+
testModel?: string,
|
| 14 |
+
): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
|
| 15 |
+
try {
|
| 16 |
+
const controller = new AbortController();
|
| 17 |
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
| 18 |
+
|
| 19 |
+
const startTime = performance.now();
|
| 20 |
+
|
| 21 |
+
// Add common headers
|
| 22 |
+
const processedHeaders = {
|
| 23 |
+
'Content-Type': 'application/json',
|
| 24 |
+
...headers,
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const response = await fetch(url, {
|
| 28 |
+
method: 'GET',
|
| 29 |
+
headers: processedHeaders,
|
| 30 |
+
signal: controller.signal,
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
const endTime = performance.now();
|
| 34 |
+
const responseTime = endTime - startTime;
|
| 35 |
+
|
| 36 |
+
clearTimeout(timeoutId);
|
| 37 |
+
|
| 38 |
+
const data = (await response.json()) as ApiResponse;
|
| 39 |
+
|
| 40 |
+
if (!response.ok) {
|
| 41 |
+
let errorMessage = `API returned status: ${response.status}`;
|
| 42 |
+
|
| 43 |
+
if (data.error?.message) {
|
| 44 |
+
errorMessage = data.error.message;
|
| 45 |
+
} else if (data.message) {
|
| 46 |
+
errorMessage = data.message;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
ok: false,
|
| 51 |
+
status: response.status,
|
| 52 |
+
message: errorMessage,
|
| 53 |
+
responseTime,
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Different providers have different model list formats
|
| 58 |
+
let models: string[] = [];
|
| 59 |
+
|
| 60 |
+
if (Array.isArray(data)) {
|
| 61 |
+
models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
|
| 62 |
+
} else if (data.data && Array.isArray(data.data)) {
|
| 63 |
+
models = data.data.map((model) => model.id || model.name || '');
|
| 64 |
+
} else if (data.models && Array.isArray(data.models)) {
|
| 65 |
+
models = data.models.map((model) => model.id || model.name || '');
|
| 66 |
+
} else if (data.model) {
|
| 67 |
+
models = [data.model];
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (!testModel || models.length > 0) {
|
| 71 |
+
return {
|
| 72 |
+
ok: true,
|
| 73 |
+
status: response.status,
|
| 74 |
+
responseTime,
|
| 75 |
+
message: 'API key is valid',
|
| 76 |
+
};
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (testModel && !models.includes(testModel)) {
|
| 80 |
+
return {
|
| 81 |
+
ok: true,
|
| 82 |
+
status: 'model_not_found',
|
| 83 |
+
message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
|
| 84 |
+
responseTime,
|
| 85 |
+
};
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
ok: true,
|
| 90 |
+
status: response.status,
|
| 91 |
+
message: 'API key is valid',
|
| 92 |
+
responseTime,
|
| 93 |
+
};
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error(`Error checking API endpoint ${url}:`, error);
|
| 96 |
+
return {
|
| 97 |
+
ok: false,
|
| 98 |
+
status: error instanceof Error ? error.message : 'Unknown error',
|
| 99 |
+
message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
|
| 100 |
+
responseTime: 0,
|
| 101 |
+
};
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
|
| 106 |
+
try {
|
| 107 |
+
const response = await fetch(url, {
|
| 108 |
+
mode: 'no-cors',
|
| 109 |
+
headers: {
|
| 110 |
+
Accept: 'text/html',
|
| 111 |
+
},
|
| 112 |
+
});
|
| 113 |
+
return response.type === 'opaque' ? 'reachable' : 'unreachable';
|
| 114 |
+
} catch (error) {
|
| 115 |
+
console.error(`Error checking ${url}:`, error);
|
| 116 |
+
return 'unreachable';
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
abstract checkStatus(): Promise<StatusCheckResult>;
|
| 121 |
+
}
|
app/components/@settings/tabs/providers/service-status/provider-factory.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
|
| 2 |
+
import { BaseProviderChecker } from './base-provider';
|
| 3 |
+
|
| 4 |
+
import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
|
| 5 |
+
import { CohereStatusChecker } from './providers/cohere';
|
| 6 |
+
import { DeepseekStatusChecker } from './providers/deepseek';
|
| 7 |
+
import { GoogleStatusChecker } from './providers/google';
|
| 8 |
+
import { GroqStatusChecker } from './providers/groq';
|
| 9 |
+
import { HuggingFaceStatusChecker } from './providers/huggingface';
|
| 10 |
+
import { HyperbolicStatusChecker } from './providers/hyperbolic';
|
| 11 |
+
import { MistralStatusChecker } from './providers/mistral';
|
| 12 |
+
import { OpenRouterStatusChecker } from './providers/openrouter';
|
| 13 |
+
import { PerplexityStatusChecker } from './providers/perplexity';
|
| 14 |
+
import { TogetherStatusChecker } from './providers/together';
|
| 15 |
+
import { XAIStatusChecker } from './providers/xai';
|
| 16 |
+
|
| 17 |
+
export class ProviderStatusCheckerFactory {
|
| 18 |
+
private static _providerConfigs: Record<ProviderName, ProviderConfig> = {
|
| 19 |
+
AmazonBedrock: {
|
| 20 |
+
statusUrl: 'https://health.aws.amazon.com/health/status',
|
| 21 |
+
apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
|
| 22 |
+
headers: {},
|
| 23 |
+
testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
|
| 24 |
+
},
|
| 25 |
+
Cohere: {
|
| 26 |
+
statusUrl: 'https://status.cohere.com/',
|
| 27 |
+
apiUrl: 'https://api.cohere.ai/v1/models',
|
| 28 |
+
headers: {},
|
| 29 |
+
testModel: 'command',
|
| 30 |
+
},
|
| 31 |
+
Deepseek: {
|
| 32 |
+
statusUrl: 'https://status.deepseek.com/',
|
| 33 |
+
apiUrl: 'https://api.deepseek.com/v1/models',
|
| 34 |
+
headers: {},
|
| 35 |
+
testModel: 'deepseek-chat',
|
| 36 |
+
},
|
| 37 |
+
Google: {
|
| 38 |
+
statusUrl: 'https://status.cloud.google.com/',
|
| 39 |
+
apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
|
| 40 |
+
headers: {},
|
| 41 |
+
testModel: 'gemini-pro',
|
| 42 |
+
},
|
| 43 |
+
Groq: {
|
| 44 |
+
statusUrl: 'https://groqstatus.com/',
|
| 45 |
+
apiUrl: 'https://api.groq.com/v1/models',
|
| 46 |
+
headers: {},
|
| 47 |
+
testModel: 'mixtral-8x7b-32768',
|
| 48 |
+
},
|
| 49 |
+
HuggingFace: {
|
| 50 |
+
statusUrl: 'https://status.huggingface.co/',
|
| 51 |
+
apiUrl: 'https://api-inference.huggingface.co/models',
|
| 52 |
+
headers: {},
|
| 53 |
+
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
| 54 |
+
},
|
| 55 |
+
Hyperbolic: {
|
| 56 |
+
statusUrl: 'https://status.hyperbolic.ai/',
|
| 57 |
+
apiUrl: 'https://api.hyperbolic.ai/v1/models',
|
| 58 |
+
headers: {},
|
| 59 |
+
testModel: 'hyperbolic-1',
|
| 60 |
+
},
|
| 61 |
+
Mistral: {
|
| 62 |
+
statusUrl: 'https://status.mistral.ai/',
|
| 63 |
+
apiUrl: 'https://api.mistral.ai/v1/models',
|
| 64 |
+
headers: {},
|
| 65 |
+
testModel: 'mistral-tiny',
|
| 66 |
+
},
|
| 67 |
+
OpenRouter: {
|
| 68 |
+
statusUrl: 'https://status.openrouter.ai/',
|
| 69 |
+
apiUrl: 'https://openrouter.ai/api/v1/models',
|
| 70 |
+
headers: {},
|
| 71 |
+
testModel: 'anthropic/claude-3-sonnet',
|
| 72 |
+
},
|
| 73 |
+
Perplexity: {
|
| 74 |
+
statusUrl: 'https://status.perplexity.com/',
|
| 75 |
+
apiUrl: 'https://api.perplexity.ai/v1/models',
|
| 76 |
+
headers: {},
|
| 77 |
+
testModel: 'pplx-7b-chat',
|
| 78 |
+
},
|
| 79 |
+
Together: {
|
| 80 |
+
statusUrl: 'https://status.together.ai/',
|
| 81 |
+
apiUrl: 'https://api.together.xyz/v1/models',
|
| 82 |
+
headers: {},
|
| 83 |
+
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
| 84 |
+
},
|
| 85 |
+
XAI: {
|
| 86 |
+
statusUrl: 'https://status.x.ai/',
|
| 87 |
+
apiUrl: 'https://api.x.ai/v1/models',
|
| 88 |
+
headers: {},
|
| 89 |
+
testModel: 'grok-1',
|
| 90 |
+
},
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
static getChecker(provider: ProviderName): BaseProviderChecker {
|
| 94 |
+
const config = this._providerConfigs[provider];
|
| 95 |
+
|
| 96 |
+
if (!config) {
|
| 97 |
+
throw new Error(`No configuration found for provider: ${provider}`);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
switch (provider) {
|
| 101 |
+
case 'AmazonBedrock':
|
| 102 |
+
return new AmazonBedrockStatusChecker(config);
|
| 103 |
+
case 'Cohere':
|
| 104 |
+
return new CohereStatusChecker(config);
|
| 105 |
+
case 'Deepseek':
|
| 106 |
+
return new DeepseekStatusChecker(config);
|
| 107 |
+
case 'Google':
|
| 108 |
+
return new GoogleStatusChecker(config);
|
| 109 |
+
case 'Groq':
|
| 110 |
+
return new GroqStatusChecker(config);
|
| 111 |
+
case 'HuggingFace':
|
| 112 |
+
return new HuggingFaceStatusChecker(config);
|
| 113 |
+
case 'Hyperbolic':
|
| 114 |
+
return new HyperbolicStatusChecker(config);
|
| 115 |
+
case 'Mistral':
|
| 116 |
+
return new MistralStatusChecker(config);
|
| 117 |
+
case 'OpenRouter':
|
| 118 |
+
return new OpenRouterStatusChecker(config);
|
| 119 |
+
case 'Perplexity':
|
| 120 |
+
return new PerplexityStatusChecker(config);
|
| 121 |
+
case 'Together':
|
| 122 |
+
return new TogetherStatusChecker(config);
|
| 123 |
+
case 'XAI':
|
| 124 |
+
return new XAIStatusChecker(config);
|
| 125 |
+
default:
|
| 126 |
+
return new (class extends BaseProviderChecker {
|
| 127 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 128 |
+
const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
|
| 129 |
+
const apiStatus = await this.checkEndpoint(this.config.apiUrl);
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 133 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 134 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 135 |
+
};
|
| 136 |
+
}
|
| 137 |
+
})(config);
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
static getProviderNames(): ProviderName[] {
|
| 142 |
+
return Object.keys(this._providerConfigs) as ProviderName[];
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
static getProviderConfig(provider: ProviderName): ProviderConfig {
|
| 146 |
+
const config = this._providerConfigs[provider];
|
| 147 |
+
|
| 148 |
+
if (!config) {
|
| 149 |
+
throw new Error(`Unknown provider: ${provider}`);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return config;
|
| 153 |
+
}
|
| 154 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class AmazonBedrockStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check AWS health status page
|
| 8 |
+
const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for Bedrock and general AWS status
|
| 12 |
+
const hasBedrockIssues =
|
| 13 |
+
text.includes('Amazon Bedrock') &&
|
| 14 |
+
(text.includes('Service is experiencing elevated error rates') ||
|
| 15 |
+
text.includes('Service disruption') ||
|
| 16 |
+
text.includes('Degraded Service'));
|
| 17 |
+
|
| 18 |
+
const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
|
| 19 |
+
|
| 20 |
+
// Extract incidents
|
| 21 |
+
const incidents: string[] = [];
|
| 22 |
+
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
|
| 23 |
+
|
| 24 |
+
for (const match of incidentMatches) {
|
| 25 |
+
const [, date, title, impact] = match;
|
| 26 |
+
|
| 27 |
+
if (title.includes('Bedrock') || title.includes('AWS')) {
|
| 28 |
+
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 33 |
+
let message = 'All services operational';
|
| 34 |
+
|
| 35 |
+
if (hasBedrockIssues) {
|
| 36 |
+
status = 'degraded';
|
| 37 |
+
message = 'Amazon Bedrock service issues reported';
|
| 38 |
+
} else if (hasGeneralIssues) {
|
| 39 |
+
status = 'degraded';
|
| 40 |
+
message = 'AWS experiencing general issues';
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// If status page check fails, fallback to endpoint check
|
| 44 |
+
if (!statusPageResponse.ok) {
|
| 45 |
+
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
|
| 46 |
+
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
|
| 47 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 51 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 52 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
status,
|
| 58 |
+
message,
|
| 59 |
+
incidents: incidents.slice(0, 5),
|
| 60 |
+
};
|
| 61 |
+
} catch (error) {
|
| 62 |
+
console.error('Error checking Amazon Bedrock status:', error);
|
| 63 |
+
|
| 64 |
+
// Fallback to basic endpoint check
|
| 65 |
+
const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
|
| 66 |
+
const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
|
| 67 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 71 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 72 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/anthropic.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class AnthropicStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.anthropic.com/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for specific Anthropic status indicators
|
| 12 |
+
const isOperational = text.includes('All Systems Operational');
|
| 13 |
+
const hasDegradedPerformance = text.includes('Degraded Performance');
|
| 14 |
+
const hasPartialOutage = text.includes('Partial Outage');
|
| 15 |
+
const hasMajorOutage = text.includes('Major Outage');
|
| 16 |
+
|
| 17 |
+
// Extract incidents
|
| 18 |
+
const incidents: string[] = [];
|
| 19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
| 20 |
+
|
| 21 |
+
if (incidentSection) {
|
| 22 |
+
const incidentLines = incidentSection[1]
|
| 23 |
+
.split('\n')
|
| 24 |
+
.map((line) => line.trim())
|
| 25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
| 26 |
+
|
| 27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 31 |
+
let message = 'All systems operational';
|
| 32 |
+
|
| 33 |
+
if (hasMajorOutage) {
|
| 34 |
+
status = 'down';
|
| 35 |
+
message = 'Major service outage';
|
| 36 |
+
} else if (hasPartialOutage) {
|
| 37 |
+
status = 'down';
|
| 38 |
+
message = 'Partial service outage';
|
| 39 |
+
} else if (hasDegradedPerformance) {
|
| 40 |
+
status = 'degraded';
|
| 41 |
+
message = 'Service experiencing degraded performance';
|
| 42 |
+
} else if (!isOperational) {
|
| 43 |
+
status = 'degraded';
|
| 44 |
+
message = 'Service status unknown';
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// If status page check fails, fallback to endpoint check
|
| 48 |
+
if (!statusPageResponse.ok) {
|
| 49 |
+
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
|
| 50 |
+
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
|
| 51 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 55 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 56 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return {
|
| 61 |
+
status,
|
| 62 |
+
message,
|
| 63 |
+
incidents,
|
| 64 |
+
};
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Error checking Anthropic status:', error);
|
| 67 |
+
|
| 68 |
+
// Fallback to basic endpoint check
|
| 69 |
+
const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
|
| 70 |
+
const apiEndpoint = 'https://api.anthropic.com/v1/messages';
|
| 71 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 72 |
+
|
| 73 |
+
return {
|
| 74 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 75 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 76 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 77 |
+
};
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/cohere.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class CohereStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.cohere.com/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for specific Cohere status indicators
|
| 12 |
+
const isOperational = text.includes('All Systems Operational');
|
| 13 |
+
const hasIncidents = text.includes('Active Incidents');
|
| 14 |
+
const hasDegradation = text.includes('Degraded Performance');
|
| 15 |
+
const hasOutage = text.includes('Service Outage');
|
| 16 |
+
|
| 17 |
+
// Extract incidents
|
| 18 |
+
const incidents: string[] = [];
|
| 19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
| 20 |
+
|
| 21 |
+
if (incidentSection) {
|
| 22 |
+
const incidentLines = incidentSection[1]
|
| 23 |
+
.split('\n')
|
| 24 |
+
.map((line) => line.trim())
|
| 25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
| 26 |
+
|
| 27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Check specific services
|
| 31 |
+
const services = {
|
| 32 |
+
api: {
|
| 33 |
+
operational: text.includes('API Service') && text.includes('Operational'),
|
| 34 |
+
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
|
| 35 |
+
outage: text.includes('API Service') && text.includes('Service Outage'),
|
| 36 |
+
},
|
| 37 |
+
generation: {
|
| 38 |
+
operational: text.includes('Generation Service') && text.includes('Operational'),
|
| 39 |
+
degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
|
| 40 |
+
outage: text.includes('Generation Service') && text.includes('Service Outage'),
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 45 |
+
let message = 'All systems operational';
|
| 46 |
+
|
| 47 |
+
if (services.api.outage || services.generation.outage || hasOutage) {
|
| 48 |
+
status = 'down';
|
| 49 |
+
message = 'Service outage detected';
|
| 50 |
+
} else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
|
| 51 |
+
status = 'degraded';
|
| 52 |
+
message = 'Service experiencing issues';
|
| 53 |
+
} else if (!isOperational) {
|
| 54 |
+
status = 'degraded';
|
| 55 |
+
message = 'Service status unknown';
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// If status page check fails, fallback to endpoint check
|
| 59 |
+
if (!statusPageResponse.ok) {
|
| 60 |
+
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
|
| 61 |
+
const apiEndpoint = 'https://api.cohere.ai/v1/models';
|
| 62 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 66 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 67 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
status,
|
| 73 |
+
message,
|
| 74 |
+
incidents,
|
| 75 |
+
};
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Error checking Cohere status:', error);
|
| 78 |
+
|
| 79 |
+
// Fallback to basic endpoint check
|
| 80 |
+
const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
|
| 81 |
+
const apiEndpoint = 'https://api.cohere.ai/v1/models';
|
| 82 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 86 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 87 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/deepseek.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class DeepseekStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
/*
|
| 8 |
+
* Check status page - Note: Deepseek doesn't have a public status page yet
|
| 9 |
+
* so we'll check their API endpoint directly
|
| 10 |
+
*/
|
| 11 |
+
const apiEndpoint = 'https://api.deepseek.com/v1/models';
|
| 12 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 13 |
+
|
| 14 |
+
// Check their website as a secondary indicator
|
| 15 |
+
const websiteStatus = await this.checkEndpoint('https://deepseek.com');
|
| 16 |
+
|
| 17 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 18 |
+
let message = 'All systems operational';
|
| 19 |
+
|
| 20 |
+
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
| 21 |
+
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
| 22 |
+
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return {
|
| 26 |
+
status,
|
| 27 |
+
message,
|
| 28 |
+
incidents: [], // No public incident tracking available yet
|
| 29 |
+
};
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error('Error checking Deepseek status:', error);
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
status: 'degraded',
|
| 35 |
+
message: 'Unable to determine service status',
|
| 36 |
+
incidents: ['Note: Limited status information available'],
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/google.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class GoogleStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.cloud.google.com/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for Vertex AI and general cloud status
|
| 12 |
+
const hasVertexAIIssues =
|
| 13 |
+
text.includes('Vertex AI') &&
|
| 14 |
+
(text.includes('Incident') ||
|
| 15 |
+
text.includes('Disruption') ||
|
| 16 |
+
text.includes('Outage') ||
|
| 17 |
+
text.includes('degraded'));
|
| 18 |
+
|
| 19 |
+
const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
|
| 20 |
+
|
| 21 |
+
// Extract incidents
|
| 22 |
+
const incidents: string[] = [];
|
| 23 |
+
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
|
| 24 |
+
|
| 25 |
+
for (const match of incidentMatches) {
|
| 26 |
+
const [, date, title, impact] = match;
|
| 27 |
+
|
| 28 |
+
if (title.includes('Vertex AI') || title.includes('Cloud')) {
|
| 29 |
+
incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 34 |
+
let message = 'All services operational';
|
| 35 |
+
|
| 36 |
+
if (hasVertexAIIssues) {
|
| 37 |
+
status = 'degraded';
|
| 38 |
+
message = 'Vertex AI service issues reported';
|
| 39 |
+
} else if (hasGeneralIssues) {
|
| 40 |
+
status = 'degraded';
|
| 41 |
+
message = 'Google Cloud experiencing issues';
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// If status page check fails, fallback to endpoint check
|
| 45 |
+
if (!statusPageResponse.ok) {
|
| 46 |
+
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
|
| 47 |
+
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
| 48 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 52 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 53 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
status,
|
| 59 |
+
message,
|
| 60 |
+
incidents: incidents.slice(0, 5),
|
| 61 |
+
};
|
| 62 |
+
} catch (error) {
|
| 63 |
+
console.error('Error checking Google status:', error);
|
| 64 |
+
|
| 65 |
+
// Fallback to basic endpoint check
|
| 66 |
+
const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
|
| 67 |
+
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
| 68 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 69 |
+
|
| 70 |
+
return {
|
| 71 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 72 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 73 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/groq.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class GroqStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://groqstatus.com/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
const isOperational = text.includes('All Systems Operational');
|
| 12 |
+
const hasIncidents = text.includes('Active Incidents');
|
| 13 |
+
const hasDegradation = text.includes('Degraded Performance');
|
| 14 |
+
const hasOutage = text.includes('Service Outage');
|
| 15 |
+
|
| 16 |
+
// Extract incidents
|
| 17 |
+
const incidents: string[] = [];
|
| 18 |
+
const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
|
| 19 |
+
|
| 20 |
+
for (const match of incidentMatches) {
|
| 21 |
+
const [, date, title, status] = match;
|
| 22 |
+
incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 26 |
+
let message = 'All systems operational';
|
| 27 |
+
|
| 28 |
+
if (hasOutage) {
|
| 29 |
+
status = 'down';
|
| 30 |
+
message = 'Service outage detected';
|
| 31 |
+
} else if (hasDegradation || hasIncidents) {
|
| 32 |
+
status = 'degraded';
|
| 33 |
+
message = 'Service experiencing issues';
|
| 34 |
+
} else if (!isOperational) {
|
| 35 |
+
status = 'degraded';
|
| 36 |
+
message = 'Service status unknown';
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// If status page check fails, fallback to endpoint check
|
| 40 |
+
if (!statusPageResponse.ok) {
|
| 41 |
+
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
|
| 42 |
+
const apiEndpoint = 'https://api.groq.com/v1/models';
|
| 43 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 47 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 48 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
status,
|
| 54 |
+
message,
|
| 55 |
+
incidents: incidents.slice(0, 5),
|
| 56 |
+
};
|
| 57 |
+
} catch (error) {
|
| 58 |
+
console.error('Error checking Groq status:', error);
|
| 59 |
+
|
| 60 |
+
// Fallback to basic endpoint check
|
| 61 |
+
const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
|
| 62 |
+
const apiEndpoint = 'https://api.groq.com/v1/models';
|
| 63 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 67 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 68 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/huggingface.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class HuggingFaceStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.huggingface.co/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for "All services are online" message
|
| 12 |
+
const allServicesOnline = text.includes('All services are online');
|
| 13 |
+
|
| 14 |
+
// Get last update time
|
| 15 |
+
const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
|
| 16 |
+
const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
|
| 17 |
+
|
| 18 |
+
// Check individual services and their uptime percentages
|
| 19 |
+
const services = {
|
| 20 |
+
'Huggingface Hub': {
|
| 21 |
+
operational: text.includes('Huggingface Hub') && text.includes('Operational'),
|
| 22 |
+
uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
| 23 |
+
},
|
| 24 |
+
'Git Hosting and Serving': {
|
| 25 |
+
operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
|
| 26 |
+
uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
| 27 |
+
},
|
| 28 |
+
'Inference API': {
|
| 29 |
+
operational: text.includes('Inference API') && text.includes('Operational'),
|
| 30 |
+
uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
| 31 |
+
},
|
| 32 |
+
'HF Endpoints': {
|
| 33 |
+
operational: text.includes('HF Endpoints') && text.includes('Operational'),
|
| 34 |
+
uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
| 35 |
+
},
|
| 36 |
+
Spaces: {
|
| 37 |
+
operational: text.includes('Spaces') && text.includes('Operational'),
|
| 38 |
+
uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
|
| 39 |
+
},
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
// Create service status messages with uptime
|
| 43 |
+
const serviceMessages = Object.entries(services).map(([name, info]) => {
|
| 44 |
+
if (info.uptime) {
|
| 45 |
+
return `${name}: ${info.uptime}% uptime`;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
// Determine overall status
|
| 52 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 53 |
+
let message = allServicesOnline
|
| 54 |
+
? `All services are online (Last updated on ${lastUpdate})`
|
| 55 |
+
: 'Checking individual services';
|
| 56 |
+
|
| 57 |
+
// Only mark as degraded if we explicitly detect issues
|
| 58 |
+
const hasIssues = Object.values(services).some((service) => !service.operational);
|
| 59 |
+
|
| 60 |
+
if (hasIssues) {
|
| 61 |
+
status = 'degraded';
|
| 62 |
+
message = `Service issues detected (Last updated on ${lastUpdate})`;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// If status page check fails, fallback to endpoint check
|
| 66 |
+
if (!statusPageResponse.ok) {
|
| 67 |
+
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
|
| 68 |
+
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
| 69 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 73 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 74 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
status,
|
| 80 |
+
message,
|
| 81 |
+
incidents: serviceMessages,
|
| 82 |
+
};
|
| 83 |
+
} catch (error) {
|
| 84 |
+
console.error('Error checking HuggingFace status:', error);
|
| 85 |
+
|
| 86 |
+
// Fallback to basic endpoint check
|
| 87 |
+
const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
|
| 88 |
+
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
| 89 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 93 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 94 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 95 |
+
};
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class HyperbolicStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
/*
|
| 8 |
+
* Check API endpoint directly since Hyperbolic is a newer provider
|
| 9 |
+
* and may not have a public status page yet
|
| 10 |
+
*/
|
| 11 |
+
const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
|
| 12 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 13 |
+
|
| 14 |
+
// Check their website as a secondary indicator
|
| 15 |
+
const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
|
| 16 |
+
|
| 17 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 18 |
+
let message = 'All systems operational';
|
| 19 |
+
|
| 20 |
+
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
| 21 |
+
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
| 22 |
+
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return {
|
| 26 |
+
status,
|
| 27 |
+
message,
|
| 28 |
+
incidents: [], // No public incident tracking available yet
|
| 29 |
+
};
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error('Error checking Hyperbolic status:', error);
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
status: 'degraded',
|
| 35 |
+
message: 'Unable to determine service status',
|
| 36 |
+
incidents: ['Note: Limited status information available'],
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/mistral.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class MistralStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.mistral.ai/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
const isOperational = text.includes('All Systems Operational');
|
| 12 |
+
const hasIncidents = text.includes('Active Incidents');
|
| 13 |
+
const hasDegradation = text.includes('Degraded Performance');
|
| 14 |
+
const hasOutage = text.includes('Service Outage');
|
| 15 |
+
|
| 16 |
+
// Extract incidents
|
| 17 |
+
const incidents: string[] = [];
|
| 18 |
+
const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s);
|
| 19 |
+
|
| 20 |
+
if (incidentSection) {
|
| 21 |
+
const incidentLines = incidentSection[1]
|
| 22 |
+
.split('\n')
|
| 23 |
+
.map((line) => line.trim())
|
| 24 |
+
.filter((line) => line && !line.includes('No incidents'));
|
| 25 |
+
|
| 26 |
+
incidents.push(...incidentLines.slice(0, 5));
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 30 |
+
let message = 'All systems operational';
|
| 31 |
+
|
| 32 |
+
if (hasOutage) {
|
| 33 |
+
status = 'down';
|
| 34 |
+
message = 'Service outage detected';
|
| 35 |
+
} else if (hasDegradation || hasIncidents) {
|
| 36 |
+
status = 'degraded';
|
| 37 |
+
message = 'Service experiencing issues';
|
| 38 |
+
} else if (!isOperational) {
|
| 39 |
+
status = 'degraded';
|
| 40 |
+
message = 'Service status unknown';
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// If status page check fails, fallback to endpoint check
|
| 44 |
+
if (!statusPageResponse.ok) {
|
| 45 |
+
const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
|
| 46 |
+
const apiEndpoint = 'https://api.mistral.ai/v1/models';
|
| 47 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 51 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 52 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
status,
|
| 58 |
+
message,
|
| 59 |
+
incidents,
|
| 60 |
+
};
|
| 61 |
+
} catch (error) {
|
| 62 |
+
console.error('Error checking Mistral status:', error);
|
| 63 |
+
|
| 64 |
+
// Fallback to basic endpoint check
|
| 65 |
+
const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
|
| 66 |
+
const apiEndpoint = 'https://api.mistral.ai/v1/models';
|
| 67 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 71 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 72 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/openai.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class OpenAIStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.openai.com/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check individual services
|
| 12 |
+
const services = {
|
| 13 |
+
api: {
|
| 14 |
+
operational: text.includes('API ? Operational'),
|
| 15 |
+
degraded: text.includes('API ? Degraded Performance'),
|
| 16 |
+
outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'),
|
| 17 |
+
},
|
| 18 |
+
chat: {
|
| 19 |
+
operational: text.includes('ChatGPT ? Operational'),
|
| 20 |
+
degraded: text.includes('ChatGPT ? Degraded Performance'),
|
| 21 |
+
outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'),
|
| 22 |
+
},
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// Extract recent incidents
|
| 26 |
+
const incidents: string[] = [];
|
| 27 |
+
const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s);
|
| 28 |
+
|
| 29 |
+
if (incidentMatches) {
|
| 30 |
+
const recentIncidents = incidentMatches[1]
|
| 31 |
+
.split('\n')
|
| 32 |
+
.map((line) => line.trim())
|
| 33 |
+
.filter((line) => line && line.includes('202')); // Get only dated incidents
|
| 34 |
+
|
| 35 |
+
incidents.push(...recentIncidents.slice(0, 5));
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Determine overall status
|
| 39 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 40 |
+
const messages: string[] = [];
|
| 41 |
+
|
| 42 |
+
if (services.api.outage || services.chat.outage) {
|
| 43 |
+
status = 'down';
|
| 44 |
+
|
| 45 |
+
if (services.api.outage) {
|
| 46 |
+
messages.push('API: Major Outage');
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (services.chat.outage) {
|
| 50 |
+
messages.push('ChatGPT: Major Outage');
|
| 51 |
+
}
|
| 52 |
+
} else if (services.api.degraded || services.chat.degraded) {
|
| 53 |
+
status = 'degraded';
|
| 54 |
+
|
| 55 |
+
if (services.api.degraded) {
|
| 56 |
+
messages.push('API: Degraded Performance');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
if (services.chat.degraded) {
|
| 60 |
+
messages.push('ChatGPT: Degraded Performance');
|
| 61 |
+
}
|
| 62 |
+
} else if (services.api.operational) {
|
| 63 |
+
messages.push('API: Operational');
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// If status page check fails, fallback to endpoint check
|
| 67 |
+
if (!statusPageResponse.ok) {
|
| 68 |
+
const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
|
| 69 |
+
const apiEndpoint = 'https://api.openai.com/v1/models';
|
| 70 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 71 |
+
|
| 72 |
+
return {
|
| 73 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 74 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 75 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 76 |
+
};
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
status,
|
| 81 |
+
message: messages.join(', ') || 'Status unknown',
|
| 82 |
+
incidents,
|
| 83 |
+
};
|
| 84 |
+
} catch (error) {
|
| 85 |
+
console.error('Error checking OpenAI status:', error);
|
| 86 |
+
|
| 87 |
+
// Fallback to basic endpoint check
|
| 88 |
+
const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
|
| 89 |
+
const apiEndpoint = 'https://api.openai.com/v1/models';
|
| 90 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 94 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 95 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/openrouter.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class OpenRouterStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.openrouter.ai/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for specific OpenRouter status indicators
|
| 12 |
+
const isOperational = text.includes('All Systems Operational');
|
| 13 |
+
const hasIncidents = text.includes('Active Incidents');
|
| 14 |
+
const hasDegradation = text.includes('Degraded Performance');
|
| 15 |
+
const hasOutage = text.includes('Service Outage');
|
| 16 |
+
|
| 17 |
+
// Extract incidents
|
| 18 |
+
const incidents: string[] = [];
|
| 19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
| 20 |
+
|
| 21 |
+
if (incidentSection) {
|
| 22 |
+
const incidentLines = incidentSection[1]
|
| 23 |
+
.split('\n')
|
| 24 |
+
.map((line) => line.trim())
|
| 25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
| 26 |
+
|
| 27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Check specific services
|
| 31 |
+
const services = {
|
| 32 |
+
api: {
|
| 33 |
+
operational: text.includes('API Service') && text.includes('Operational'),
|
| 34 |
+
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
|
| 35 |
+
outage: text.includes('API Service') && text.includes('Service Outage'),
|
| 36 |
+
},
|
| 37 |
+
routing: {
|
| 38 |
+
operational: text.includes('Routing Service') && text.includes('Operational'),
|
| 39 |
+
degraded: text.includes('Routing Service') && text.includes('Degraded Performance'),
|
| 40 |
+
outage: text.includes('Routing Service') && text.includes('Service Outage'),
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 45 |
+
let message = 'All systems operational';
|
| 46 |
+
|
| 47 |
+
if (services.api.outage || services.routing.outage || hasOutage) {
|
| 48 |
+
status = 'down';
|
| 49 |
+
message = 'Service outage detected';
|
| 50 |
+
} else if (services.api.degraded || services.routing.degraded || hasDegradation || hasIncidents) {
|
| 51 |
+
status = 'degraded';
|
| 52 |
+
message = 'Service experiencing issues';
|
| 53 |
+
} else if (!isOperational) {
|
| 54 |
+
status = 'degraded';
|
| 55 |
+
message = 'Service status unknown';
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// If status page check fails, fallback to endpoint check
|
| 59 |
+
if (!statusPageResponse.ok) {
|
| 60 |
+
const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
|
| 61 |
+
const apiEndpoint = 'https://openrouter.ai/api/v1/models';
|
| 62 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 66 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 67 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
status,
|
| 73 |
+
message,
|
| 74 |
+
incidents,
|
| 75 |
+
};
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Error checking OpenRouter status:', error);
|
| 78 |
+
|
| 79 |
+
// Fallback to basic endpoint check
|
| 80 |
+
const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
|
| 81 |
+
const apiEndpoint = 'https://openrouter.ai/api/v1/models';
|
| 82 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 86 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 87 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/perplexity.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class PerplexityStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.perplexity.ai/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for specific Perplexity status indicators
|
| 12 |
+
const isOperational = text.includes('All Systems Operational');
|
| 13 |
+
const hasIncidents = text.includes('Active Incidents');
|
| 14 |
+
const hasDegradation = text.includes('Degraded Performance');
|
| 15 |
+
const hasOutage = text.includes('Service Outage');
|
| 16 |
+
|
| 17 |
+
// Extract incidents
|
| 18 |
+
const incidents: string[] = [];
|
| 19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
| 20 |
+
|
| 21 |
+
if (incidentSection) {
|
| 22 |
+
const incidentLines = incidentSection[1]
|
| 23 |
+
.split('\n')
|
| 24 |
+
.map((line) => line.trim())
|
| 25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
| 26 |
+
|
| 27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Check specific services
|
| 31 |
+
const services = {
|
| 32 |
+
api: {
|
| 33 |
+
operational: text.includes('API Service') && text.includes('Operational'),
|
| 34 |
+
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
|
| 35 |
+
outage: text.includes('API Service') && text.includes('Service Outage'),
|
| 36 |
+
},
|
| 37 |
+
inference: {
|
| 38 |
+
operational: text.includes('Inference Service') && text.includes('Operational'),
|
| 39 |
+
degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
|
| 40 |
+
outage: text.includes('Inference Service') && text.includes('Service Outage'),
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 45 |
+
let message = 'All systems operational';
|
| 46 |
+
|
| 47 |
+
if (services.api.outage || services.inference.outage || hasOutage) {
|
| 48 |
+
status = 'down';
|
| 49 |
+
message = 'Service outage detected';
|
| 50 |
+
} else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
|
| 51 |
+
status = 'degraded';
|
| 52 |
+
message = 'Service experiencing issues';
|
| 53 |
+
} else if (!isOperational) {
|
| 54 |
+
status = 'degraded';
|
| 55 |
+
message = 'Service status unknown';
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// If status page check fails, fallback to endpoint check
|
| 59 |
+
if (!statusPageResponse.ok) {
|
| 60 |
+
const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
|
| 61 |
+
const apiEndpoint = 'https://api.perplexity.ai/v1/models';
|
| 62 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 66 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 67 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
status,
|
| 73 |
+
message,
|
| 74 |
+
incidents,
|
| 75 |
+
};
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Error checking Perplexity status:', error);
|
| 78 |
+
|
| 79 |
+
// Fallback to basic endpoint check
|
| 80 |
+
const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
|
| 81 |
+
const apiEndpoint = 'https://api.perplexity.ai/v1/models';
|
| 82 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 86 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 87 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/together.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class TogetherStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
// Check status page
|
| 8 |
+
const statusPageResponse = await fetch('https://status.together.ai/');
|
| 9 |
+
const text = await statusPageResponse.text();
|
| 10 |
+
|
| 11 |
+
// Check for specific Together status indicators
|
| 12 |
+
const isOperational = text.includes('All Systems Operational');
|
| 13 |
+
const hasIncidents = text.includes('Active Incidents');
|
| 14 |
+
const hasDegradation = text.includes('Degraded Performance');
|
| 15 |
+
const hasOutage = text.includes('Service Outage');
|
| 16 |
+
|
| 17 |
+
// Extract incidents
|
| 18 |
+
const incidents: string[] = [];
|
| 19 |
+
const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
|
| 20 |
+
|
| 21 |
+
if (incidentSection) {
|
| 22 |
+
const incidentLines = incidentSection[1]
|
| 23 |
+
.split('\n')
|
| 24 |
+
.map((line) => line.trim())
|
| 25 |
+
.filter((line) => line && line.includes('202')); // Only get dated incidents
|
| 26 |
+
|
| 27 |
+
incidents.push(...incidentLines.slice(0, 5));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Check specific services
|
| 31 |
+
const services = {
|
| 32 |
+
api: {
|
| 33 |
+
operational: text.includes('API Service') && text.includes('Operational'),
|
| 34 |
+
degraded: text.includes('API Service') && text.includes('Degraded Performance'),
|
| 35 |
+
outage: text.includes('API Service') && text.includes('Service Outage'),
|
| 36 |
+
},
|
| 37 |
+
inference: {
|
| 38 |
+
operational: text.includes('Inference Service') && text.includes('Operational'),
|
| 39 |
+
degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
|
| 40 |
+
outage: text.includes('Inference Service') && text.includes('Service Outage'),
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 45 |
+
let message = 'All systems operational';
|
| 46 |
+
|
| 47 |
+
if (services.api.outage || services.inference.outage || hasOutage) {
|
| 48 |
+
status = 'down';
|
| 49 |
+
message = 'Service outage detected';
|
| 50 |
+
} else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
|
| 51 |
+
status = 'degraded';
|
| 52 |
+
message = 'Service experiencing issues';
|
| 53 |
+
} else if (!isOperational) {
|
| 54 |
+
status = 'degraded';
|
| 55 |
+
message = 'Service status unknown';
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// If status page check fails, fallback to endpoint check
|
| 59 |
+
if (!statusPageResponse.ok) {
|
| 60 |
+
const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
|
| 61 |
+
const apiEndpoint = 'https://api.together.ai/v1/models';
|
| 62 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 66 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 67 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
status,
|
| 73 |
+
message,
|
| 74 |
+
incidents,
|
| 75 |
+
};
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Error checking Together status:', error);
|
| 78 |
+
|
| 79 |
+
// Fallback to basic endpoint check
|
| 80 |
+
const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
|
| 81 |
+
const apiEndpoint = 'https://api.together.ai/v1/models';
|
| 82 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 86 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 87 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
app/components/@settings/tabs/providers/service-status/providers/xai.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
|
| 2 |
+
import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
|
| 3 |
+
|
| 4 |
+
export class XAIStatusChecker extends BaseProviderChecker {
|
| 5 |
+
async checkStatus(): Promise<StatusCheckResult> {
|
| 6 |
+
try {
|
| 7 |
+
/*
|
| 8 |
+
* Check API endpoint directly since XAI is a newer provider
|
| 9 |
+
* and may not have a public status page yet
|
| 10 |
+
*/
|
| 11 |
+
const apiEndpoint = 'https://api.xai.com/v1/models';
|
| 12 |
+
const apiStatus = await this.checkEndpoint(apiEndpoint);
|
| 13 |
+
|
| 14 |
+
// Check their website as a secondary indicator
|
| 15 |
+
const websiteStatus = await this.checkEndpoint('https://x.ai');
|
| 16 |
+
|
| 17 |
+
let status: StatusCheckResult['status'] = 'operational';
|
| 18 |
+
let message = 'All systems operational';
|
| 19 |
+
|
| 20 |
+
if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
|
| 21 |
+
status = apiStatus !== 'reachable' ? 'down' : 'degraded';
|
| 22 |
+
message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return {
|
| 26 |
+
status,
|
| 27 |
+
message,
|
| 28 |
+
incidents: [], // No public incident tracking available yet
|
| 29 |
+
};
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error('Error checking XAI status:', error);
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
status: 'degraded',
|
| 35 |
+
message: 'Unable to determine service status',
|
| 36 |
+
incidents: ['Note: Limited status information available'],
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
app/components/@settings/tabs/providers/service-status/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { IconType } from 'react-icons';
|
| 2 |
+
|
| 3 |
+
export type ProviderName =
|
| 4 |
+
| 'AmazonBedrock'
|
| 5 |
+
| 'Cohere'
|
| 6 |
+
| 'Deepseek'
|
| 7 |
+
| 'Google'
|
| 8 |
+
| 'Groq'
|
| 9 |
+
| 'HuggingFace'
|
| 10 |
+
| 'Hyperbolic'
|
| 11 |
+
| 'Mistral'
|
| 12 |
+
| 'OpenRouter'
|
| 13 |
+
| 'Perplexity'
|
| 14 |
+
| 'Together'
|
| 15 |
+
| 'XAI';
|
| 16 |
+
|
| 17 |
+
export type ServiceStatus = {
|
| 18 |
+
provider: ProviderName;
|
| 19 |
+
status: 'operational' | 'degraded' | 'down';
|
| 20 |
+
lastChecked: string;
|
| 21 |
+
statusUrl?: string;
|
| 22 |
+
icon?: IconType;
|
| 23 |
+
message?: string;
|
| 24 |
+
responseTime?: number;
|
| 25 |
+
incidents?: string[];
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export interface ProviderConfig {
|
| 29 |
+
statusUrl: string;
|
| 30 |
+
apiUrl: string;
|
| 31 |
+
headers: Record<string, string>;
|
| 32 |
+
testModel: string;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export type ApiResponse = {
|
| 36 |
+
error?: {
|
| 37 |
+
message: string;
|
| 38 |
+
};
|
| 39 |
+
message?: string;
|
| 40 |
+
model?: string;
|
| 41 |
+
models?: Array<{
|
| 42 |
+
id?: string;
|
| 43 |
+
name?: string;
|
| 44 |
+
}>;
|
| 45 |
+
data?: Array<{
|
| 46 |
+
id?: string;
|
| 47 |
+
name?: string;
|
| 48 |
+
}>;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
export type StatusCheckResult = {
|
| 52 |
+
status: 'operational' | 'degraded' | 'down';
|
| 53 |
+
message: string;
|
| 54 |
+
incidents: string[];
|
| 55 |
+
};
|
app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { TbActivityHeartbeat } from 'react-icons/tb';
|
| 5 |
+
import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs';
|
| 6 |
+
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
|
| 7 |
+
import { BsRobot, BsCloud } from 'react-icons/bs';
|
| 8 |
+
import { TbBrain } from 'react-icons/tb';
|
| 9 |
+
import { BiChip, BiCodeBlock } from 'react-icons/bi';
|
| 10 |
+
import { FaCloud, FaBrain } from 'react-icons/fa';
|
| 11 |
+
import type { IconType } from 'react-icons';
|
| 12 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 13 |
+
import { useToast } from '~/components/ui/use-toast';
|
| 14 |
+
|
| 15 |
+
// Types
|
| 16 |
+
type ProviderName =
|
| 17 |
+
| 'AmazonBedrock'
|
| 18 |
+
| 'Anthropic'
|
| 19 |
+
| 'Cohere'
|
| 20 |
+
| 'Deepseek'
|
| 21 |
+
| 'Google'
|
| 22 |
+
| 'Groq'
|
| 23 |
+
| 'HuggingFace'
|
| 24 |
+
| 'Mistral'
|
| 25 |
+
| 'OpenAI'
|
| 26 |
+
| 'OpenRouter'
|
| 27 |
+
| 'Perplexity'
|
| 28 |
+
| 'Together'
|
| 29 |
+
| 'XAI';
|
| 30 |
+
|
| 31 |
+
type ServiceStatus = {
|
| 32 |
+
provider: ProviderName;
|
| 33 |
+
status: 'operational' | 'degraded' | 'down';
|
| 34 |
+
lastChecked: string;
|
| 35 |
+
statusUrl?: string;
|
| 36 |
+
icon?: IconType;
|
| 37 |
+
message?: string;
|
| 38 |
+
responseTime?: number;
|
| 39 |
+
incidents?: string[];
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
type ProviderConfig = {
|
| 43 |
+
statusUrl: string;
|
| 44 |
+
apiUrl: string;
|
| 45 |
+
headers: Record<string, string>;
|
| 46 |
+
testModel: string;
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// Types for API responses
|
| 50 |
+
type ApiResponse = {
|
| 51 |
+
error?: {
|
| 52 |
+
message: string;
|
| 53 |
+
};
|
| 54 |
+
message?: string;
|
| 55 |
+
model?: string;
|
| 56 |
+
models?: Array<{
|
| 57 |
+
id?: string;
|
| 58 |
+
name?: string;
|
| 59 |
+
}>;
|
| 60 |
+
data?: Array<{
|
| 61 |
+
id?: string;
|
| 62 |
+
name?: string;
|
| 63 |
+
}>;
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
// Constants
|
| 67 |
+
const PROVIDER_STATUS_URLS: Record<ProviderName, ProviderConfig> = {
|
| 68 |
+
OpenAI: {
|
| 69 |
+
statusUrl: 'https://status.openai.com/',
|
| 70 |
+
apiUrl: 'https://api.openai.com/v1/models',
|
| 71 |
+
headers: {
|
| 72 |
+
Authorization: 'Bearer $OPENAI_API_KEY',
|
| 73 |
+
},
|
| 74 |
+
testModel: 'gpt-3.5-turbo',
|
| 75 |
+
},
|
| 76 |
+
Anthropic: {
|
| 77 |
+
statusUrl: 'https://status.anthropic.com/',
|
| 78 |
+
apiUrl: 'https://api.anthropic.com/v1/messages',
|
| 79 |
+
headers: {
|
| 80 |
+
'x-api-key': '$ANTHROPIC_API_KEY',
|
| 81 |
+
'anthropic-version': '2024-02-29',
|
| 82 |
+
},
|
| 83 |
+
testModel: 'claude-3-sonnet-20240229',
|
| 84 |
+
},
|
| 85 |
+
Cohere: {
|
| 86 |
+
statusUrl: 'https://status.cohere.com/',
|
| 87 |
+
apiUrl: 'https://api.cohere.ai/v1/models',
|
| 88 |
+
headers: {
|
| 89 |
+
Authorization: 'Bearer $COHERE_API_KEY',
|
| 90 |
+
},
|
| 91 |
+
testModel: 'command',
|
| 92 |
+
},
|
| 93 |
+
Google: {
|
| 94 |
+
statusUrl: 'https://status.cloud.google.com/',
|
| 95 |
+
apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
|
| 96 |
+
headers: {
|
| 97 |
+
'x-goog-api-key': '$GOOGLE_API_KEY',
|
| 98 |
+
},
|
| 99 |
+
testModel: 'gemini-pro',
|
| 100 |
+
},
|
| 101 |
+
HuggingFace: {
|
| 102 |
+
statusUrl: 'https://status.huggingface.co/',
|
| 103 |
+
apiUrl: 'https://api-inference.huggingface.co/models',
|
| 104 |
+
headers: {
|
| 105 |
+
Authorization: 'Bearer $HUGGINGFACE_API_KEY',
|
| 106 |
+
},
|
| 107 |
+
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
| 108 |
+
},
|
| 109 |
+
Mistral: {
|
| 110 |
+
statusUrl: 'https://status.mistral.ai/',
|
| 111 |
+
apiUrl: 'https://api.mistral.ai/v1/models',
|
| 112 |
+
headers: {
|
| 113 |
+
Authorization: 'Bearer $MISTRAL_API_KEY',
|
| 114 |
+
},
|
| 115 |
+
testModel: 'mistral-tiny',
|
| 116 |
+
},
|
| 117 |
+
Perplexity: {
|
| 118 |
+
statusUrl: 'https://status.perplexity.com/',
|
| 119 |
+
apiUrl: 'https://api.perplexity.ai/v1/models',
|
| 120 |
+
headers: {
|
| 121 |
+
Authorization: 'Bearer $PERPLEXITY_API_KEY',
|
| 122 |
+
},
|
| 123 |
+
testModel: 'pplx-7b-chat',
|
| 124 |
+
},
|
| 125 |
+
Together: {
|
| 126 |
+
statusUrl: 'https://status.together.ai/',
|
| 127 |
+
apiUrl: 'https://api.together.xyz/v1/models',
|
| 128 |
+
headers: {
|
| 129 |
+
Authorization: 'Bearer $TOGETHER_API_KEY',
|
| 130 |
+
},
|
| 131 |
+
testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
|
| 132 |
+
},
|
| 133 |
+
AmazonBedrock: {
|
| 134 |
+
statusUrl: 'https://health.aws.amazon.com/health/status',
|
| 135 |
+
apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
|
| 136 |
+
headers: {
|
| 137 |
+
Authorization: 'Bearer $AWS_BEDROCK_CONFIG',
|
| 138 |
+
},
|
| 139 |
+
testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
|
| 140 |
+
},
|
| 141 |
+
Groq: {
|
| 142 |
+
statusUrl: 'https://groqstatus.com/',
|
| 143 |
+
apiUrl: 'https://api.groq.com/v1/models',
|
| 144 |
+
headers: {
|
| 145 |
+
Authorization: 'Bearer $GROQ_API_KEY',
|
| 146 |
+
},
|
| 147 |
+
testModel: 'mixtral-8x7b-32768',
|
| 148 |
+
},
|
| 149 |
+
OpenRouter: {
|
| 150 |
+
statusUrl: 'https://status.openrouter.ai/',
|
| 151 |
+
apiUrl: 'https://openrouter.ai/api/v1/models',
|
| 152 |
+
headers: {
|
| 153 |
+
Authorization: 'Bearer $OPEN_ROUTER_API_KEY',
|
| 154 |
+
},
|
| 155 |
+
testModel: 'anthropic/claude-3-sonnet',
|
| 156 |
+
},
|
| 157 |
+
XAI: {
|
| 158 |
+
statusUrl: 'https://status.x.ai/',
|
| 159 |
+
apiUrl: 'https://api.x.ai/v1/models',
|
| 160 |
+
headers: {
|
| 161 |
+
Authorization: 'Bearer $XAI_API_KEY',
|
| 162 |
+
},
|
| 163 |
+
testModel: 'grok-1',
|
| 164 |
+
},
|
| 165 |
+
Deepseek: {
|
| 166 |
+
statusUrl: 'https://status.deepseek.com/',
|
| 167 |
+
apiUrl: 'https://api.deepseek.com/v1/models',
|
| 168 |
+
headers: {
|
| 169 |
+
Authorization: 'Bearer $DEEPSEEK_API_KEY',
|
| 170 |
+
},
|
| 171 |
+
testModel: 'deepseek-chat',
|
| 172 |
+
},
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
| 176 |
+
AmazonBedrock: SiAmazon,
|
| 177 |
+
Anthropic: FaBrain,
|
| 178 |
+
Cohere: BiChip,
|
| 179 |
+
Google: SiGoogle,
|
| 180 |
+
Groq: BsCloud,
|
| 181 |
+
HuggingFace: SiHuggingface,
|
| 182 |
+
Mistral: TbBrain,
|
| 183 |
+
OpenAI: SiOpenai,
|
| 184 |
+
OpenRouter: FaCloud,
|
| 185 |
+
Perplexity: SiPerplexity,
|
| 186 |
+
Together: BsCloud,
|
| 187 |
+
XAI: BsRobot,
|
| 188 |
+
Deepseek: BiCodeBlock,
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const ServiceStatusTab = () => {
|
| 192 |
+
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
| 193 |
+
const [loading, setLoading] = useState(true);
|
| 194 |
+
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
| 195 |
+
const [testApiKey, setTestApiKey] = useState<string>('');
|
| 196 |
+
const [testProvider, setTestProvider] = useState<ProviderName | ''>('');
|
| 197 |
+
const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
|
| 198 |
+
const settings = useSettings();
|
| 199 |
+
const { success, error } = useToast();
|
| 200 |
+
|
| 201 |
+
// Function to get the API key for a provider from environment variables
|
| 202 |
+
const getApiKey = useCallback(
|
| 203 |
+
(provider: ProviderName): string | null => {
|
| 204 |
+
if (!settings.providers) {
|
| 205 |
+
return null;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Map provider names to environment variable names
|
| 209 |
+
const envKeyMap: Record<ProviderName, string> = {
|
| 210 |
+
OpenAI: 'OPENAI_API_KEY',
|
| 211 |
+
Anthropic: 'ANTHROPIC_API_KEY',
|
| 212 |
+
Cohere: 'COHERE_API_KEY',
|
| 213 |
+
Google: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
| 214 |
+
HuggingFace: 'HuggingFace_API_KEY',
|
| 215 |
+
Mistral: 'MISTRAL_API_KEY',
|
| 216 |
+
Perplexity: 'PERPLEXITY_API_KEY',
|
| 217 |
+
Together: 'TOGETHER_API_KEY',
|
| 218 |
+
AmazonBedrock: 'AWS_BEDROCK_CONFIG',
|
| 219 |
+
Groq: 'GROQ_API_KEY',
|
| 220 |
+
OpenRouter: 'OPEN_ROUTER_API_KEY',
|
| 221 |
+
XAI: 'XAI_API_KEY',
|
| 222 |
+
Deepseek: 'DEEPSEEK_API_KEY',
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
const envKey = envKeyMap[provider];
|
| 226 |
+
|
| 227 |
+
if (!envKey) {
|
| 228 |
+
return null;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Get the API key from environment variables
|
| 232 |
+
const apiKey = (import.meta.env[envKey] as string) || null;
|
| 233 |
+
|
| 234 |
+
// Special handling for providers with base URLs
|
| 235 |
+
if (provider === 'Together' && apiKey) {
|
| 236 |
+
const baseUrl = import.meta.env.TOGETHER_API_BASE_URL;
|
| 237 |
+
|
| 238 |
+
if (!baseUrl) {
|
| 239 |
+
return null;
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
return apiKey;
|
| 244 |
+
},
|
| 245 |
+
[settings.providers],
|
| 246 |
+
);
|
| 247 |
+
|
| 248 |
+
// Update provider configurations based on available API keys
|
| 249 |
+
const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => {
|
| 250 |
+
const config = PROVIDER_STATUS_URLS[provider];
|
| 251 |
+
|
| 252 |
+
if (!config) {
|
| 253 |
+
return null;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Handle special cases for providers with base URLs
|
| 257 |
+
let updatedConfig = { ...config };
|
| 258 |
+
const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL;
|
| 259 |
+
|
| 260 |
+
if (provider === 'Together' && togetherBaseUrl) {
|
| 261 |
+
updatedConfig = {
|
| 262 |
+
...config,
|
| 263 |
+
apiUrl: `${togetherBaseUrl}/models`,
|
| 264 |
+
};
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
return updatedConfig;
|
| 268 |
+
}, []);
|
| 269 |
+
|
| 270 |
+
// Function to check if an API endpoint is accessible with model verification
|
| 271 |
+
const checkApiEndpoint = useCallback(
|
| 272 |
+
async (
|
| 273 |
+
url: string,
|
| 274 |
+
headers?: Record<string, string>,
|
| 275 |
+
testModel?: string,
|
| 276 |
+
): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => {
|
| 277 |
+
try {
|
| 278 |
+
const controller = new AbortController();
|
| 279 |
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
| 280 |
+
|
| 281 |
+
const startTime = performance.now();
|
| 282 |
+
|
| 283 |
+
// Add common headers
|
| 284 |
+
const processedHeaders = {
|
| 285 |
+
'Content-Type': 'application/json',
|
| 286 |
+
...headers,
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
// First check if the API is accessible
|
| 290 |
+
const response = await fetch(url, {
|
| 291 |
+
method: 'GET',
|
| 292 |
+
headers: processedHeaders,
|
| 293 |
+
signal: controller.signal,
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
const endTime = performance.now();
|
| 297 |
+
const responseTime = endTime - startTime;
|
| 298 |
+
|
| 299 |
+
clearTimeout(timeoutId);
|
| 300 |
+
|
| 301 |
+
// Get response data
|
| 302 |
+
const data = (await response.json()) as ApiResponse;
|
| 303 |
+
|
| 304 |
+
// Special handling for different provider responses
|
| 305 |
+
if (!response.ok) {
|
| 306 |
+
let errorMessage = `API returned status: ${response.status}`;
|
| 307 |
+
|
| 308 |
+
// Handle provider-specific error messages
|
| 309 |
+
if (data.error?.message) {
|
| 310 |
+
errorMessage = data.error.message;
|
| 311 |
+
} else if (data.message) {
|
| 312 |
+
errorMessage = data.message;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return {
|
| 316 |
+
ok: false,
|
| 317 |
+
status: response.status,
|
| 318 |
+
message: errorMessage,
|
| 319 |
+
responseTime,
|
| 320 |
+
};
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
// Different providers have different model list formats
|
| 324 |
+
let models: string[] = [];
|
| 325 |
+
|
| 326 |
+
if (Array.isArray(data)) {
|
| 327 |
+
models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
|
| 328 |
+
} else if (data.data && Array.isArray(data.data)) {
|
| 329 |
+
models = data.data.map((model) => model.id || model.name || '');
|
| 330 |
+
} else if (data.models && Array.isArray(data.models)) {
|
| 331 |
+
models = data.models.map((model) => model.id || model.name || '');
|
| 332 |
+
} else if (data.model) {
|
| 333 |
+
// Some providers return single model info
|
| 334 |
+
models = [data.model];
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// For some providers, just having a successful response is enough
|
| 338 |
+
if (!testModel || models.length > 0) {
|
| 339 |
+
return {
|
| 340 |
+
ok: true,
|
| 341 |
+
status: response.status,
|
| 342 |
+
responseTime,
|
| 343 |
+
message: 'API key is valid',
|
| 344 |
+
};
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// If a specific model was requested, verify it exists
|
| 348 |
+
if (testModel && !models.includes(testModel)) {
|
| 349 |
+
return {
|
| 350 |
+
ok: true, // Still mark as ok since API works
|
| 351 |
+
status: 'model_not_found',
|
| 352 |
+
message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
|
| 353 |
+
responseTime,
|
| 354 |
+
};
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
return {
|
| 358 |
+
ok: true,
|
| 359 |
+
status: response.status,
|
| 360 |
+
message: 'API key is valid',
|
| 361 |
+
responseTime,
|
| 362 |
+
};
|
| 363 |
+
} catch (error) {
|
| 364 |
+
console.error(`Error checking API endpoint ${url}:`, error);
|
| 365 |
+
return {
|
| 366 |
+
ok: false,
|
| 367 |
+
status: error instanceof Error ? error.message : 'Unknown error',
|
| 368 |
+
message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
|
| 369 |
+
responseTime: 0,
|
| 370 |
+
};
|
| 371 |
+
}
|
| 372 |
+
},
|
| 373 |
+
[getApiKey],
|
| 374 |
+
);
|
| 375 |
+
|
| 376 |
+
// Function to fetch real status from provider status pages
|
| 377 |
+
const fetchPublicStatus = useCallback(
|
| 378 |
+
async (
|
| 379 |
+
provider: ProviderName,
|
| 380 |
+
): Promise<{
|
| 381 |
+
status: ServiceStatus['status'];
|
| 382 |
+
message?: string;
|
| 383 |
+
incidents?: string[];
|
| 384 |
+
}> => {
|
| 385 |
+
try {
|
| 386 |
+
// Due to CORS restrictions, we can only check if the endpoints are reachable
|
| 387 |
+
const checkEndpoint = async (url: string) => {
|
| 388 |
+
try {
|
| 389 |
+
const response = await fetch(url, {
|
| 390 |
+
mode: 'no-cors',
|
| 391 |
+
headers: {
|
| 392 |
+
Accept: 'text/html',
|
| 393 |
+
},
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
// With no-cors, we can only know if the request succeeded
|
| 397 |
+
return response.type === 'opaque' ? 'reachable' : 'unreachable';
|
| 398 |
+
} catch (error) {
|
| 399 |
+
console.error(`Error checking ${url}:`, error);
|
| 400 |
+
return 'unreachable';
|
| 401 |
+
}
|
| 402 |
+
};
|
| 403 |
+
|
| 404 |
+
switch (provider) {
|
| 405 |
+
case 'HuggingFace': {
|
| 406 |
+
const endpointStatus = await checkEndpoint('https://status.huggingface.co/');
|
| 407 |
+
|
| 408 |
+
// Check API endpoint as fallback
|
| 409 |
+
const apiEndpoint = 'https://api-inference.huggingface.co/models';
|
| 410 |
+
const apiStatus = await checkEndpoint(apiEndpoint);
|
| 411 |
+
|
| 412 |
+
return {
|
| 413 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 414 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 415 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 416 |
+
};
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
case 'OpenAI': {
|
| 420 |
+
const endpointStatus = await checkEndpoint('https://status.openai.com/');
|
| 421 |
+
const apiEndpoint = 'https://api.openai.com/v1/models';
|
| 422 |
+
const apiStatus = await checkEndpoint(apiEndpoint);
|
| 423 |
+
|
| 424 |
+
return {
|
| 425 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 426 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 427 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 428 |
+
};
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
case 'Google': {
|
| 432 |
+
const endpointStatus = await checkEndpoint('https://status.cloud.google.com/');
|
| 433 |
+
const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
|
| 434 |
+
const apiStatus = await checkEndpoint(apiEndpoint);
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
|
| 438 |
+
message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
|
| 439 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 440 |
+
};
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// Similar pattern for other providers...
|
| 444 |
+
default:
|
| 445 |
+
return {
|
| 446 |
+
status: 'operational',
|
| 447 |
+
message: 'Basic reachability check only',
|
| 448 |
+
incidents: ['Note: Limited status information due to CORS restrictions'],
|
| 449 |
+
};
|
| 450 |
+
}
|
| 451 |
+
} catch (error) {
|
| 452 |
+
console.error(`Error fetching status for ${provider}:`, error);
|
| 453 |
+
return {
|
| 454 |
+
status: 'degraded',
|
| 455 |
+
message: 'Unable to fetch status due to CORS restrictions',
|
| 456 |
+
incidents: ['Error: Unable to check service status'],
|
| 457 |
+
};
|
| 458 |
+
}
|
| 459 |
+
},
|
| 460 |
+
[],
|
| 461 |
+
);
|
| 462 |
+
|
| 463 |
+
// Function to fetch status for a provider with retries
|
| 464 |
+
const fetchProviderStatus = useCallback(
|
| 465 |
+
async (provider: ProviderName, config: ProviderConfig): Promise<ServiceStatus> => {
|
| 466 |
+
const MAX_RETRIES = 2;
|
| 467 |
+
const RETRY_DELAY = 2000; // 2 seconds
|
| 468 |
+
|
| 469 |
+
const attemptCheck = async (attempt: number): Promise<ServiceStatus> => {
|
| 470 |
+
try {
|
| 471 |
+
// First check the public status page if available
|
| 472 |
+
const hasPublicStatus = [
|
| 473 |
+
'Anthropic',
|
| 474 |
+
'OpenAI',
|
| 475 |
+
'Google',
|
| 476 |
+
'HuggingFace',
|
| 477 |
+
'Mistral',
|
| 478 |
+
'Groq',
|
| 479 |
+
'Perplexity',
|
| 480 |
+
'Together',
|
| 481 |
+
].includes(provider);
|
| 482 |
+
|
| 483 |
+
if (hasPublicStatus) {
|
| 484 |
+
const publicStatus = await fetchPublicStatus(provider);
|
| 485 |
+
|
| 486 |
+
return {
|
| 487 |
+
provider,
|
| 488 |
+
status: publicStatus.status,
|
| 489 |
+
lastChecked: new Date().toISOString(),
|
| 490 |
+
statusUrl: config.statusUrl,
|
| 491 |
+
icon: PROVIDER_ICONS[provider],
|
| 492 |
+
message: publicStatus.message,
|
| 493 |
+
incidents: publicStatus.incidents,
|
| 494 |
+
};
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// For other providers, we'll show status but mark API check as separate
|
| 498 |
+
const apiKey = getApiKey(provider);
|
| 499 |
+
const providerConfig = getProviderConfig(provider);
|
| 500 |
+
|
| 501 |
+
if (!apiKey || !providerConfig) {
|
| 502 |
+
return {
|
| 503 |
+
provider,
|
| 504 |
+
status: 'operational',
|
| 505 |
+
lastChecked: new Date().toISOString(),
|
| 506 |
+
statusUrl: config.statusUrl,
|
| 507 |
+
icon: PROVIDER_ICONS[provider],
|
| 508 |
+
message: !apiKey
|
| 509 |
+
? 'Status operational (API key needed for usage)'
|
| 510 |
+
: 'Status operational (configuration needed for usage)',
|
| 511 |
+
incidents: [],
|
| 512 |
+
};
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
// If we have API access, let's verify that too
|
| 516 |
+
const { ok, status, message, responseTime } = await checkApiEndpoint(
|
| 517 |
+
providerConfig.apiUrl,
|
| 518 |
+
providerConfig.headers,
|
| 519 |
+
providerConfig.testModel,
|
| 520 |
+
);
|
| 521 |
+
|
| 522 |
+
if (!ok && attempt < MAX_RETRIES) {
|
| 523 |
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
|
| 524 |
+
return attemptCheck(attempt + 1);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
return {
|
| 528 |
+
provider,
|
| 529 |
+
status: ok ? 'operational' : 'degraded',
|
| 530 |
+
lastChecked: new Date().toISOString(),
|
| 531 |
+
statusUrl: providerConfig.statusUrl,
|
| 532 |
+
icon: PROVIDER_ICONS[provider],
|
| 533 |
+
message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`,
|
| 534 |
+
responseTime,
|
| 535 |
+
incidents: [],
|
| 536 |
+
};
|
| 537 |
+
} catch (error) {
|
| 538 |
+
console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error);
|
| 539 |
+
|
| 540 |
+
if (attempt < MAX_RETRIES) {
|
| 541 |
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
|
| 542 |
+
return attemptCheck(attempt + 1);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
return {
|
| 546 |
+
provider,
|
| 547 |
+
status: 'degraded',
|
| 548 |
+
lastChecked: new Date().toISOString(),
|
| 549 |
+
statusUrl: config.statusUrl,
|
| 550 |
+
icon: PROVIDER_ICONS[provider],
|
| 551 |
+
message: 'Service operational (Status check error)',
|
| 552 |
+
responseTime: 0,
|
| 553 |
+
incidents: [],
|
| 554 |
+
};
|
| 555 |
+
}
|
| 556 |
+
};
|
| 557 |
+
|
| 558 |
+
return attemptCheck(1);
|
| 559 |
+
},
|
| 560 |
+
[checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus],
|
| 561 |
+
);
|
| 562 |
+
|
| 563 |
+
// Memoize the fetchAllStatuses function
|
| 564 |
+
const fetchAllStatuses = useCallback(async () => {
|
| 565 |
+
try {
|
| 566 |
+
setLoading(true);
|
| 567 |
+
|
| 568 |
+
const statuses = await Promise.all(
|
| 569 |
+
Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) =>
|
| 570 |
+
fetchProviderStatus(provider as ProviderName, config),
|
| 571 |
+
),
|
| 572 |
+
);
|
| 573 |
+
|
| 574 |
+
setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider)));
|
| 575 |
+
setLastRefresh(new Date());
|
| 576 |
+
success('Service statuses updated successfully');
|
| 577 |
+
} catch (err) {
|
| 578 |
+
console.error('Error fetching all statuses:', err);
|
| 579 |
+
error('Failed to update service statuses');
|
| 580 |
+
} finally {
|
| 581 |
+
setLoading(false);
|
| 582 |
+
}
|
| 583 |
+
}, [fetchProviderStatus, success, error]);
|
| 584 |
+
|
| 585 |
+
useEffect(() => {
|
| 586 |
+
fetchAllStatuses();
|
| 587 |
+
|
| 588 |
+
// Refresh status every 2 minutes
|
| 589 |
+
const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000);
|
| 590 |
+
|
| 591 |
+
return () => clearInterval(interval);
|
| 592 |
+
}, [fetchAllStatuses]);
|
| 593 |
+
|
| 594 |
+
// Function to test an API key
|
| 595 |
+
const testApiKeyForProvider = useCallback(
|
| 596 |
+
async (provider: ProviderName, apiKey: string) => {
|
| 597 |
+
try {
|
| 598 |
+
setTestingStatus('testing');
|
| 599 |
+
|
| 600 |
+
const config = PROVIDER_STATUS_URLS[provider];
|
| 601 |
+
|
| 602 |
+
if (!config) {
|
| 603 |
+
throw new Error('Provider configuration not found');
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
const headers = { ...config.headers };
|
| 607 |
+
|
| 608 |
+
// Replace the placeholder API key with the test key
|
| 609 |
+
Object.keys(headers).forEach((key) => {
|
| 610 |
+
if (headers[key].startsWith('$')) {
|
| 611 |
+
headers[key] = headers[key].replace(/\$.*/, apiKey);
|
| 612 |
+
}
|
| 613 |
+
});
|
| 614 |
+
|
| 615 |
+
// Special handling for certain providers
|
| 616 |
+
switch (provider) {
|
| 617 |
+
case 'Anthropic':
|
| 618 |
+
headers['anthropic-version'] = '2024-02-29';
|
| 619 |
+
break;
|
| 620 |
+
case 'OpenAI':
|
| 621 |
+
if (!headers.Authorization?.startsWith('Bearer ')) {
|
| 622 |
+
headers.Authorization = `Bearer ${apiKey}`;
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
break;
|
| 626 |
+
case 'Google': {
|
| 627 |
+
// Google uses the API key directly in the URL
|
| 628 |
+
const googleUrl = `${config.apiUrl}?key=${apiKey}`;
|
| 629 |
+
const result = await checkApiEndpoint(googleUrl, {}, config.testModel);
|
| 630 |
+
|
| 631 |
+
if (result.ok) {
|
| 632 |
+
setTestingStatus('success');
|
| 633 |
+
success('API key is valid!');
|
| 634 |
+
} else {
|
| 635 |
+
setTestingStatus('error');
|
| 636 |
+
error(`API key test failed: ${result.message}`);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
return;
|
| 640 |
+
}
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel);
|
| 644 |
+
|
| 645 |
+
if (ok) {
|
| 646 |
+
setTestingStatus('success');
|
| 647 |
+
success('API key is valid!');
|
| 648 |
+
} else {
|
| 649 |
+
setTestingStatus('error');
|
| 650 |
+
error(`API key test failed: ${message}`);
|
| 651 |
+
}
|
| 652 |
+
} catch (err: unknown) {
|
| 653 |
+
setTestingStatus('error');
|
| 654 |
+
error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
| 655 |
+
} finally {
|
| 656 |
+
// Reset testing status after a delay
|
| 657 |
+
setTimeout(() => setTestingStatus('idle'), 3000);
|
| 658 |
+
}
|
| 659 |
+
},
|
| 660 |
+
[checkApiEndpoint, success, error],
|
| 661 |
+
);
|
| 662 |
+
|
| 663 |
+
const getStatusColor = (status: ServiceStatus['status']) => {
|
| 664 |
+
switch (status) {
|
| 665 |
+
case 'operational':
|
| 666 |
+
return 'text-green-500';
|
| 667 |
+
case 'degraded':
|
| 668 |
+
return 'text-yellow-500';
|
| 669 |
+
case 'down':
|
| 670 |
+
return 'text-red-500';
|
| 671 |
+
default:
|
| 672 |
+
return 'text-gray-500';
|
| 673 |
+
}
|
| 674 |
+
};
|
| 675 |
+
|
| 676 |
+
const getStatusIcon = (status: ServiceStatus['status']) => {
|
| 677 |
+
switch (status) {
|
| 678 |
+
case 'operational':
|
| 679 |
+
return <BsCheckCircleFill className="w-4 h-4" />;
|
| 680 |
+
case 'degraded':
|
| 681 |
+
return <BsExclamationCircleFill className="w-4 h-4" />;
|
| 682 |
+
case 'down':
|
| 683 |
+
return <BsXCircleFill className="w-4 h-4" />;
|
| 684 |
+
default:
|
| 685 |
+
return <BsXCircleFill className="w-4 h-4" />;
|
| 686 |
+
}
|
| 687 |
+
};
|
| 688 |
+
|
| 689 |
+
return (
|
| 690 |
+
<div className="space-y-6">
|
| 691 |
+
<motion.div
|
| 692 |
+
className="space-y-4"
|
| 693 |
+
initial={{ opacity: 0, y: 20 }}
|
| 694 |
+
animate={{ opacity: 1, y: 0 }}
|
| 695 |
+
transition={{ duration: 0.3 }}
|
| 696 |
+
>
|
| 697 |
+
<div className="flex items-center justify-between gap-2 mt-8 mb-4">
|
| 698 |
+
<div className="flex items-center gap-2">
|
| 699 |
+
<div
|
| 700 |
+
className={classNames(
|
| 701 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
| 702 |
+
'bg-bolt-elements-background-depth-3',
|
| 703 |
+
'text-purple-500',
|
| 704 |
+
)}
|
| 705 |
+
>
|
| 706 |
+
<TbActivityHeartbeat className="w-5 h-5" />
|
| 707 |
+
</div>
|
| 708 |
+
<div>
|
| 709 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Service Status</h4>
|
| 710 |
+
<p className="text-sm text-bolt-elements-textSecondary">
|
| 711 |
+
Monitor and test the operational status of cloud LLM providers
|
| 712 |
+
</p>
|
| 713 |
+
</div>
|
| 714 |
+
</div>
|
| 715 |
+
<div className="flex items-center gap-2">
|
| 716 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
| 717 |
+
Last updated: {lastRefresh.toLocaleTimeString()}
|
| 718 |
+
</span>
|
| 719 |
+
<button
|
| 720 |
+
onClick={() => fetchAllStatuses()}
|
| 721 |
+
className={classNames(
|
| 722 |
+
'px-3 py-1.5 rounded-lg text-sm',
|
| 723 |
+
'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
|
| 724 |
+
'text-bolt-elements-textPrimary',
|
| 725 |
+
'transition-all duration-200',
|
| 726 |
+
'flex items-center gap-2',
|
| 727 |
+
loading ? 'opacity-50 cursor-not-allowed' : '',
|
| 728 |
+
)}
|
| 729 |
+
disabled={loading}
|
| 730 |
+
>
|
| 731 |
+
<div className={`i-ph:arrows-clockwise w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
| 732 |
+
<span>{loading ? 'Refreshing...' : 'Refresh'}</span>
|
| 733 |
+
</button>
|
| 734 |
+
</div>
|
| 735 |
+
</div>
|
| 736 |
+
|
| 737 |
+
{/* API Key Test Section */}
|
| 738 |
+
<div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
|
| 739 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Test API Key</h5>
|
| 740 |
+
<div className="flex gap-2">
|
| 741 |
+
<select
|
| 742 |
+
value={testProvider}
|
| 743 |
+
onChange={(e) => setTestProvider(e.target.value as ProviderName)}
|
| 744 |
+
className={classNames(
|
| 745 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm max-w-[200px]',
|
| 746 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 747 |
+
'text-bolt-elements-textPrimary',
|
| 748 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 749 |
+
)}
|
| 750 |
+
>
|
| 751 |
+
<option value="">Select Provider</option>
|
| 752 |
+
{Object.keys(PROVIDER_STATUS_URLS).map((provider) => (
|
| 753 |
+
<option key={provider} value={provider}>
|
| 754 |
+
{provider}
|
| 755 |
+
</option>
|
| 756 |
+
))}
|
| 757 |
+
</select>
|
| 758 |
+
<input
|
| 759 |
+
type="password"
|
| 760 |
+
value={testApiKey}
|
| 761 |
+
onChange={(e) => setTestApiKey(e.target.value)}
|
| 762 |
+
placeholder="Enter API key to test"
|
| 763 |
+
className={classNames(
|
| 764 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
| 765 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
| 766 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
| 767 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 768 |
+
)}
|
| 769 |
+
/>
|
| 770 |
+
<button
|
| 771 |
+
onClick={() =>
|
| 772 |
+
testProvider && testApiKey && testApiKeyForProvider(testProvider as ProviderName, testApiKey)
|
| 773 |
+
}
|
| 774 |
+
disabled={!testProvider || !testApiKey || testingStatus === 'testing'}
|
| 775 |
+
className={classNames(
|
| 776 |
+
'px-4 py-1.5 rounded-lg text-sm',
|
| 777 |
+
'bg-purple-500 hover:bg-purple-600',
|
| 778 |
+
'text-white',
|
| 779 |
+
'transition-all duration-200',
|
| 780 |
+
'flex items-center gap-2',
|
| 781 |
+
!testProvider || !testApiKey || testingStatus === 'testing' ? 'opacity-50 cursor-not-allowed' : '',
|
| 782 |
+
)}
|
| 783 |
+
>
|
| 784 |
+
{testingStatus === 'testing' ? (
|
| 785 |
+
<>
|
| 786 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
| 787 |
+
<span>Testing...</span>
|
| 788 |
+
</>
|
| 789 |
+
) : (
|
| 790 |
+
<>
|
| 791 |
+
<div className="i-ph:key w-4 h-4" />
|
| 792 |
+
<span>Test Key</span>
|
| 793 |
+
</>
|
| 794 |
+
)}
|
| 795 |
+
</button>
|
| 796 |
+
</div>
|
| 797 |
+
</div>
|
| 798 |
+
|
| 799 |
+
{/* Status Grid */}
|
| 800 |
+
{loading && serviceStatuses.length === 0 ? (
|
| 801 |
+
<div className="text-center py-8 text-bolt-elements-textSecondary">Loading service statuses...</div>
|
| 802 |
+
) : (
|
| 803 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 804 |
+
{serviceStatuses.map((service, index) => (
|
| 805 |
+
<motion.div
|
| 806 |
+
key={service.provider}
|
| 807 |
+
className={classNames(
|
| 808 |
+
'bg-bolt-elements-background-depth-2',
|
| 809 |
+
'hover:bg-bolt-elements-background-depth-3',
|
| 810 |
+
'transition-all duration-200',
|
| 811 |
+
'relative overflow-hidden rounded-lg',
|
| 812 |
+
)}
|
| 813 |
+
initial={{ opacity: 0, y: 20 }}
|
| 814 |
+
animate={{ opacity: 1, y: 0 }}
|
| 815 |
+
transition={{ delay: index * 0.1 }}
|
| 816 |
+
whileHover={{ scale: 1.02 }}
|
| 817 |
+
>
|
| 818 |
+
<div
|
| 819 |
+
className={classNames('block p-4', service.statusUrl ? 'cursor-pointer' : '')}
|
| 820 |
+
onClick={() => service.statusUrl && window.open(service.statusUrl, '_blank')}
|
| 821 |
+
>
|
| 822 |
+
<div className="flex items-center justify-between gap-4">
|
| 823 |
+
<div className="flex items-center gap-3">
|
| 824 |
+
{service.icon && (
|
| 825 |
+
<div
|
| 826 |
+
className={classNames(
|
| 827 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
| 828 |
+
'bg-bolt-elements-background-depth-3',
|
| 829 |
+
getStatusColor(service.status),
|
| 830 |
+
)}
|
| 831 |
+
>
|
| 832 |
+
{React.createElement(service.icon, {
|
| 833 |
+
className: 'w-5 h-5',
|
| 834 |
+
})}
|
| 835 |
+
</div>
|
| 836 |
+
)}
|
| 837 |
+
<div>
|
| 838 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{service.provider}</h4>
|
| 839 |
+
<div className="space-y-1">
|
| 840 |
+
<p className="text-xs text-bolt-elements-textSecondary">
|
| 841 |
+
Last checked: {new Date(service.lastChecked).toLocaleTimeString()}
|
| 842 |
+
</p>
|
| 843 |
+
{service.responseTime && (
|
| 844 |
+
<p className="text-xs text-bolt-elements-textTertiary">
|
| 845 |
+
Response time: {Math.round(service.responseTime)}ms
|
| 846 |
+
</p>
|
| 847 |
+
)}
|
| 848 |
+
{service.message && (
|
| 849 |
+
<p className="text-xs text-bolt-elements-textTertiary">{service.message}</p>
|
| 850 |
+
)}
|
| 851 |
+
</div>
|
| 852 |
+
</div>
|
| 853 |
+
</div>
|
| 854 |
+
<div className={classNames('flex items-center gap-2', getStatusColor(service.status))}>
|
| 855 |
+
<span className="text-sm capitalize">{service.status}</span>
|
| 856 |
+
{getStatusIcon(service.status)}
|
| 857 |
+
</div>
|
| 858 |
+
</div>
|
| 859 |
+
{service.incidents && service.incidents.length > 0 && (
|
| 860 |
+
<div className="mt-2 border-t border-bolt-elements-borderColor pt-2">
|
| 861 |
+
<p className="text-xs font-medium text-bolt-elements-textSecondary mb-1">Recent Incidents:</p>
|
| 862 |
+
<ul className="text-xs text-bolt-elements-textTertiary space-y-1">
|
| 863 |
+
{service.incidents.map((incident, i) => (
|
| 864 |
+
<li key={i}>{incident}</li>
|
| 865 |
+
))}
|
| 866 |
+
</ul>
|
| 867 |
+
</div>
|
| 868 |
+
)}
|
| 869 |
+
</div>
|
| 870 |
+
</motion.div>
|
| 871 |
+
))}
|
| 872 |
+
</div>
|
| 873 |
+
)}
|
| 874 |
+
</motion.div>
|
| 875 |
+
</div>
|
| 876 |
+
);
|
| 877 |
+
};
|
| 878 |
+
|
| 879 |
+
// Add tab metadata
|
| 880 |
+
ServiceStatusTab.tabMetadata = {
|
| 881 |
+
icon: 'i-ph:activity-bold',
|
| 882 |
+
description: 'Monitor and test LLM provider service status',
|
| 883 |
+
category: 'services',
|
| 884 |
+
};
|
| 885 |
+
|
| 886 |
+
export default ServiceStatusTab;
|
app/components/@settings/tabs/settings/SettingsTab.tsx
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { toast } from 'react-toastify';
|
| 4 |
+
import { classNames } from '~/utils/classNames';
|
| 5 |
+
import { Switch } from '~/components/ui/Switch';
|
| 6 |
+
import type { UserProfile } from '~/components/@settings/core/types';
|
| 7 |
+
import { isMac } from '~/utils/os';
|
| 8 |
+
|
| 9 |
+
// Helper to get modifier key symbols/text
|
| 10 |
+
const getModifierSymbol = (modifier: string): string => {
|
| 11 |
+
switch (modifier) {
|
| 12 |
+
case 'meta':
|
| 13 |
+
return isMac ? '⌘' : 'Win';
|
| 14 |
+
case 'alt':
|
| 15 |
+
return isMac ? '⌥' : 'Alt';
|
| 16 |
+
case 'shift':
|
| 17 |
+
return '⇧';
|
| 18 |
+
default:
|
| 19 |
+
return modifier;
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export default function SettingsTab() {
|
| 24 |
+
const [currentTimezone, setCurrentTimezone] = useState('');
|
| 25 |
+
const [settings, setSettings] = useState<UserProfile>(() => {
|
| 26 |
+
const saved = localStorage.getItem('bolt_user_profile');
|
| 27 |
+
return saved
|
| 28 |
+
? JSON.parse(saved)
|
| 29 |
+
: {
|
| 30 |
+
notifications: true,
|
| 31 |
+
language: 'en',
|
| 32 |
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
| 33 |
+
};
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
// Save settings automatically when they change
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
try {
|
| 43 |
+
// Get existing profile data
|
| 44 |
+
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
|
| 45 |
+
|
| 46 |
+
// Merge with new settings
|
| 47 |
+
const updatedProfile = {
|
| 48 |
+
...existingProfile,
|
| 49 |
+
notifications: settings.notifications,
|
| 50 |
+
language: settings.language,
|
| 51 |
+
timezone: settings.timezone,
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
|
| 55 |
+
toast.success('Settings updated');
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error('Error saving settings:', error);
|
| 58 |
+
toast.error('Failed to update settings');
|
| 59 |
+
}
|
| 60 |
+
}, [settings]);
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="space-y-4">
|
| 64 |
+
{/* Language & Notifications */}
|
| 65 |
+
<motion.div
|
| 66 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
|
| 67 |
+
initial={{ opacity: 0, y: 20 }}
|
| 68 |
+
animate={{ opacity: 1, y: 0 }}
|
| 69 |
+
transition={{ delay: 0.1 }}
|
| 70 |
+
>
|
| 71 |
+
<div className="flex items-center gap-2 mb-4">
|
| 72 |
+
<div className="i-ph:palette-fill w-4 h-4 text-purple-500" />
|
| 73 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Preferences</span>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div>
|
| 77 |
+
<div className="flex items-center gap-2 mb-2">
|
| 78 |
+
<div className="i-ph:translate-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
| 79 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Language</label>
|
| 80 |
+
</div>
|
| 81 |
+
<select
|
| 82 |
+
value={settings.language}
|
| 83 |
+
onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
|
| 84 |
+
className={classNames(
|
| 85 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 86 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 87 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 88 |
+
'text-bolt-elements-textPrimary',
|
| 89 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 90 |
+
'transition-all duration-200',
|
| 91 |
+
)}
|
| 92 |
+
>
|
| 93 |
+
<option value="en">English</option>
|
| 94 |
+
<option value="es">Español</option>
|
| 95 |
+
<option value="fr">Français</option>
|
| 96 |
+
<option value="de">Deutsch</option>
|
| 97 |
+
<option value="it">Italiano</option>
|
| 98 |
+
<option value="pt">Português</option>
|
| 99 |
+
<option value="ru">Русский</option>
|
| 100 |
+
<option value="zh">中文</option>
|
| 101 |
+
<option value="ja">日本語</option>
|
| 102 |
+
<option value="ko">한국어</option>
|
| 103 |
+
</select>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div>
|
| 107 |
+
<div className="flex items-center gap-2 mb-2">
|
| 108 |
+
<div className="i-ph:bell-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
| 109 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Notifications</label>
|
| 110 |
+
</div>
|
| 111 |
+
<div className="flex items-center justify-between">
|
| 112 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
| 113 |
+
{settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
|
| 114 |
+
</span>
|
| 115 |
+
<Switch
|
| 116 |
+
checked={settings.notifications}
|
| 117 |
+
onCheckedChange={(checked) => {
|
| 118 |
+
// Update local state
|
| 119 |
+
setSettings((prev) => ({ ...prev, notifications: checked }));
|
| 120 |
+
|
| 121 |
+
// Update localStorage immediately
|
| 122 |
+
const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
|
| 123 |
+
const updatedProfile = {
|
| 124 |
+
...existingProfile,
|
| 125 |
+
notifications: checked,
|
| 126 |
+
};
|
| 127 |
+
localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
|
| 128 |
+
|
| 129 |
+
// Dispatch storage event for other components
|
| 130 |
+
window.dispatchEvent(
|
| 131 |
+
new StorageEvent('storage', {
|
| 132 |
+
key: 'bolt_user_profile',
|
| 133 |
+
newValue: JSON.stringify(updatedProfile),
|
| 134 |
+
}),
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
|
| 138 |
+
}}
|
| 139 |
+
/>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</motion.div>
|
| 143 |
+
|
| 144 |
+
{/* Timezone */}
|
| 145 |
+
<motion.div
|
| 146 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
|
| 147 |
+
initial={{ opacity: 0, y: 20 }}
|
| 148 |
+
animate={{ opacity: 1, y: 0 }}
|
| 149 |
+
transition={{ delay: 0.2 }}
|
| 150 |
+
>
|
| 151 |
+
<div className="flex items-center gap-2 mb-4">
|
| 152 |
+
<div className="i-ph:clock-fill w-4 h-4 text-purple-500" />
|
| 153 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Time Settings</span>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div>
|
| 157 |
+
<div className="flex items-center gap-2 mb-2">
|
| 158 |
+
<div className="i-ph:globe-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
| 159 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Timezone</label>
|
| 160 |
+
</div>
|
| 161 |
+
<select
|
| 162 |
+
value={settings.timezone}
|
| 163 |
+
onChange={(e) => setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
|
| 164 |
+
className={classNames(
|
| 165 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
| 166 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
| 167 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 168 |
+
'text-bolt-elements-textPrimary',
|
| 169 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
| 170 |
+
'transition-all duration-200',
|
| 171 |
+
)}
|
| 172 |
+
>
|
| 173 |
+
<option value={currentTimezone}>{currentTimezone}</option>
|
| 174 |
+
</select>
|
| 175 |
+
</div>
|
| 176 |
+
</motion.div>
|
| 177 |
+
|
| 178 |
+
{/* Simplified Keyboard Shortcuts */}
|
| 179 |
+
<motion.div
|
| 180 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
|
| 181 |
+
initial={{ opacity: 0, y: 20 }}
|
| 182 |
+
animate={{ opacity: 1, y: 0 }}
|
| 183 |
+
transition={{ delay: 0.3 }}
|
| 184 |
+
>
|
| 185 |
+
<div className="flex items-center gap-2 mb-4">
|
| 186 |
+
<div className="i-ph:keyboard-fill w-4 h-4 text-purple-500" />
|
| 187 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Keyboard Shortcuts</span>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<div className="space-y-2">
|
| 191 |
+
<div className="flex items-center justify-between p-2 rounded-lg bg-[#FAFAFA] dark:bg-[#1A1A1A]">
|
| 192 |
+
<div className="flex flex-col">
|
| 193 |
+
<span className="text-sm text-bolt-elements-textPrimary">Toggle Theme</span>
|
| 194 |
+
<span className="text-xs text-bolt-elements-textSecondary">Switch between light and dark mode</span>
|
| 195 |
+
</div>
|
| 196 |
+
<div className="flex items-center gap-1">
|
| 197 |
+
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
| 198 |
+
{getModifierSymbol('meta')}
|
| 199 |
+
</kbd>
|
| 200 |
+
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
| 201 |
+
{getModifierSymbol('alt')}
|
| 202 |
+
</kbd>
|
| 203 |
+
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
| 204 |
+
{getModifierSymbol('shift')}
|
| 205 |
+
</kbd>
|
| 206 |
+
<kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
|
| 207 |
+
D
|
| 208 |
+
</kbd>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</motion.div>
|
| 213 |
+
</div>
|
| 214 |
+
);
|
| 215 |
+
}
|
app/components/@settings/tabs/task-manager/TaskManagerTab.tsx
ADDED
|
@@ -0,0 +1,1265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react';
|
| 2 |
+
import { useEffect, useState, useRef, useCallback } from 'react';
|
| 3 |
+
import { classNames } from '~/utils/classNames';
|
| 4 |
+
import { Line } from 'react-chartjs-2';
|
| 5 |
+
import {
|
| 6 |
+
Chart as ChartJS,
|
| 7 |
+
CategoryScale,
|
| 8 |
+
LinearScale,
|
| 9 |
+
PointElement,
|
| 10 |
+
LineElement,
|
| 11 |
+
Title,
|
| 12 |
+
Tooltip,
|
| 13 |
+
Legend,
|
| 14 |
+
} from 'chart.js';
|
| 15 |
+
import { toast } from 'react-toastify'; // Import toast
|
| 16 |
+
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
|
| 17 |
+
import { tabConfigurationStore, type TabConfig } from '~/lib/stores/tabConfigurationStore';
|
| 18 |
+
import { useStore } from 'zustand';
|
| 19 |
+
|
| 20 |
+
// Register ChartJS components
|
| 21 |
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
| 22 |
+
|
| 23 |
+
interface BatteryManager extends EventTarget {
|
| 24 |
+
charging: boolean;
|
| 25 |
+
chargingTime: number;
|
| 26 |
+
dischargingTime: number;
|
| 27 |
+
level: number;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
interface SystemMetrics {
|
| 31 |
+
cpu: {
|
| 32 |
+
usage: number;
|
| 33 |
+
cores: number[];
|
| 34 |
+
temperature?: number;
|
| 35 |
+
frequency?: number;
|
| 36 |
+
};
|
| 37 |
+
memory: {
|
| 38 |
+
used: number;
|
| 39 |
+
total: number;
|
| 40 |
+
percentage: number;
|
| 41 |
+
heap: {
|
| 42 |
+
used: number;
|
| 43 |
+
total: number;
|
| 44 |
+
limit: number;
|
| 45 |
+
};
|
| 46 |
+
cache?: number;
|
| 47 |
+
};
|
| 48 |
+
uptime: number;
|
| 49 |
+
battery?: {
|
| 50 |
+
level: number;
|
| 51 |
+
charging: boolean;
|
| 52 |
+
timeRemaining?: number;
|
| 53 |
+
temperature?: number;
|
| 54 |
+
cycles?: number;
|
| 55 |
+
health?: number;
|
| 56 |
+
};
|
| 57 |
+
network: {
|
| 58 |
+
downlink: number;
|
| 59 |
+
uplink?: number;
|
| 60 |
+
latency: number;
|
| 61 |
+
type: string;
|
| 62 |
+
activeConnections?: number;
|
| 63 |
+
bytesReceived: number;
|
| 64 |
+
bytesSent: number;
|
| 65 |
+
};
|
| 66 |
+
performance: {
|
| 67 |
+
fps: number;
|
| 68 |
+
pageLoad: number;
|
| 69 |
+
domReady: number;
|
| 70 |
+
resources: {
|
| 71 |
+
total: number;
|
| 72 |
+
size: number;
|
| 73 |
+
loadTime: number;
|
| 74 |
+
};
|
| 75 |
+
timing: {
|
| 76 |
+
ttfb: number;
|
| 77 |
+
fcp: number;
|
| 78 |
+
lcp: number;
|
| 79 |
+
};
|
| 80 |
+
};
|
| 81 |
+
health: {
|
| 82 |
+
score: number;
|
| 83 |
+
issues: string[];
|
| 84 |
+
suggestions: string[];
|
| 85 |
+
};
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
interface MetricsHistory {
|
| 89 |
+
timestamps: string[];
|
| 90 |
+
cpu: number[];
|
| 91 |
+
memory: number[];
|
| 92 |
+
battery: number[];
|
| 93 |
+
network: number[];
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
interface EnergySavings {
|
| 97 |
+
updatesReduced: number;
|
| 98 |
+
timeInSaverMode: number;
|
| 99 |
+
estimatedEnergySaved: number; // in mWh (milliwatt-hours)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
interface PowerProfile {
|
| 103 |
+
name: string;
|
| 104 |
+
description: string;
|
| 105 |
+
settings: {
|
| 106 |
+
updateInterval: number;
|
| 107 |
+
enableAnimations: boolean;
|
| 108 |
+
backgroundProcessing: boolean;
|
| 109 |
+
networkThrottling: boolean;
|
| 110 |
+
};
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
interface PerformanceAlert {
|
| 114 |
+
type: 'warning' | 'error' | 'info';
|
| 115 |
+
message: string;
|
| 116 |
+
timestamp: number;
|
| 117 |
+
metric: string;
|
| 118 |
+
threshold: number;
|
| 119 |
+
value: number;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
declare global {
|
| 123 |
+
interface Navigator {
|
| 124 |
+
getBattery(): Promise<BatteryManager>;
|
| 125 |
+
}
|
| 126 |
+
interface Performance {
|
| 127 |
+
memory?: {
|
| 128 |
+
jsHeapSizeLimit: number;
|
| 129 |
+
totalJSHeapSize: number;
|
| 130 |
+
usedJSHeapSize: number;
|
| 131 |
+
};
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Constants for update intervals
|
| 136 |
+
const UPDATE_INTERVALS = {
|
| 137 |
+
normal: {
|
| 138 |
+
metrics: 1000, // 1 second
|
| 139 |
+
animation: 16, // ~60fps
|
| 140 |
+
},
|
| 141 |
+
energySaver: {
|
| 142 |
+
metrics: 5000, // 5 seconds
|
| 143 |
+
animation: 32, // ~30fps
|
| 144 |
+
},
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
// Constants for performance thresholds
|
| 148 |
+
const PERFORMANCE_THRESHOLDS = {
|
| 149 |
+
cpu: {
|
| 150 |
+
warning: 70,
|
| 151 |
+
critical: 90,
|
| 152 |
+
},
|
| 153 |
+
memory: {
|
| 154 |
+
warning: 80,
|
| 155 |
+
critical: 95,
|
| 156 |
+
},
|
| 157 |
+
fps: {
|
| 158 |
+
warning: 30,
|
| 159 |
+
critical: 15,
|
| 160 |
+
},
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
// Constants for energy calculations
|
| 164 |
+
const ENERGY_COSTS = {
|
| 165 |
+
update: 0.1, // mWh per update
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
// Default power profiles
|
| 169 |
+
const POWER_PROFILES: PowerProfile[] = [
|
| 170 |
+
{
|
| 171 |
+
name: 'Performance',
|
| 172 |
+
description: 'Maximum performance with frequent updates',
|
| 173 |
+
settings: {
|
| 174 |
+
updateInterval: UPDATE_INTERVALS.normal.metrics,
|
| 175 |
+
enableAnimations: true,
|
| 176 |
+
backgroundProcessing: true,
|
| 177 |
+
networkThrottling: false,
|
| 178 |
+
},
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
name: 'Balanced',
|
| 182 |
+
description: 'Optimal balance between performance and energy efficiency',
|
| 183 |
+
settings: {
|
| 184 |
+
updateInterval: 2000,
|
| 185 |
+
enableAnimations: true,
|
| 186 |
+
backgroundProcessing: true,
|
| 187 |
+
networkThrottling: false,
|
| 188 |
+
},
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
name: 'Energy Saver',
|
| 192 |
+
description: 'Maximum energy efficiency with reduced updates',
|
| 193 |
+
settings: {
|
| 194 |
+
updateInterval: UPDATE_INTERVALS.energySaver.metrics,
|
| 195 |
+
enableAnimations: false,
|
| 196 |
+
backgroundProcessing: false,
|
| 197 |
+
networkThrottling: true,
|
| 198 |
+
},
|
| 199 |
+
},
|
| 200 |
+
];
|
| 201 |
+
|
| 202 |
+
// Default metrics state
|
| 203 |
+
const DEFAULT_METRICS_STATE: SystemMetrics = {
|
| 204 |
+
cpu: {
|
| 205 |
+
usage: 0,
|
| 206 |
+
cores: [],
|
| 207 |
+
},
|
| 208 |
+
memory: {
|
| 209 |
+
used: 0,
|
| 210 |
+
total: 0,
|
| 211 |
+
percentage: 0,
|
| 212 |
+
heap: {
|
| 213 |
+
used: 0,
|
| 214 |
+
total: 0,
|
| 215 |
+
limit: 0,
|
| 216 |
+
},
|
| 217 |
+
},
|
| 218 |
+
uptime: 0,
|
| 219 |
+
network: {
|
| 220 |
+
downlink: 0,
|
| 221 |
+
latency: 0,
|
| 222 |
+
type: 'unknown',
|
| 223 |
+
bytesReceived: 0,
|
| 224 |
+
bytesSent: 0,
|
| 225 |
+
},
|
| 226 |
+
performance: {
|
| 227 |
+
fps: 0,
|
| 228 |
+
pageLoad: 0,
|
| 229 |
+
domReady: 0,
|
| 230 |
+
resources: {
|
| 231 |
+
total: 0,
|
| 232 |
+
size: 0,
|
| 233 |
+
loadTime: 0,
|
| 234 |
+
},
|
| 235 |
+
timing: {
|
| 236 |
+
ttfb: 0,
|
| 237 |
+
fcp: 0,
|
| 238 |
+
lcp: 0,
|
| 239 |
+
},
|
| 240 |
+
},
|
| 241 |
+
health: {
|
| 242 |
+
score: 0,
|
| 243 |
+
issues: [],
|
| 244 |
+
suggestions: [],
|
| 245 |
+
},
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
// Default metrics history
|
| 249 |
+
const DEFAULT_METRICS_HISTORY: MetricsHistory = {
|
| 250 |
+
timestamps: Array(10).fill(new Date().toLocaleTimeString()),
|
| 251 |
+
cpu: Array(10).fill(0),
|
| 252 |
+
memory: Array(10).fill(0),
|
| 253 |
+
battery: Array(10).fill(0),
|
| 254 |
+
network: Array(10).fill(0),
|
| 255 |
+
};
|
| 256 |
+
|
| 257 |
+
// Battery threshold for auto energy saver mode
|
| 258 |
+
const BATTERY_THRESHOLD = 20; // percentage
|
| 259 |
+
|
| 260 |
+
// Maximum number of history points to keep
|
| 261 |
+
const MAX_HISTORY_POINTS = 10;
|
| 262 |
+
|
| 263 |
+
const TaskManagerTab: React.FC = () => {
|
| 264 |
+
// Initialize metrics state with defaults
|
| 265 |
+
const [metrics, setMetrics] = useState<SystemMetrics>(() => DEFAULT_METRICS_STATE);
|
| 266 |
+
const [metricsHistory, setMetricsHistory] = useState<MetricsHistory>(() => DEFAULT_METRICS_HISTORY);
|
| 267 |
+
const [energySaverMode, setEnergySaverMode] = useState<boolean>(false);
|
| 268 |
+
const [autoEnergySaver, setAutoEnergySaver] = useState<boolean>(false);
|
| 269 |
+
const [energySavings, setEnergySavings] = useState<EnergySavings>(() => ({
|
| 270 |
+
updatesReduced: 0,
|
| 271 |
+
timeInSaverMode: 0,
|
| 272 |
+
estimatedEnergySaved: 0,
|
| 273 |
+
}));
|
| 274 |
+
const [selectedProfile, setSelectedProfile] = useState<PowerProfile>(() => POWER_PROFILES[1]);
|
| 275 |
+
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
| 276 |
+
const saverModeStartTime = useRef<number | null>(null);
|
| 277 |
+
|
| 278 |
+
// Get update status and tab configuration
|
| 279 |
+
const { hasUpdate } = useUpdateCheck();
|
| 280 |
+
const tabConfig = useStore(tabConfigurationStore);
|
| 281 |
+
|
| 282 |
+
const resetTabConfiguration = useCallback(() => {
|
| 283 |
+
tabConfig.reset();
|
| 284 |
+
return tabConfig.get();
|
| 285 |
+
}, [tabConfig]);
|
| 286 |
+
|
| 287 |
+
// Effect to handle tab visibility
|
| 288 |
+
useEffect(() => {
|
| 289 |
+
const handleTabVisibility = () => {
|
| 290 |
+
const currentConfig = tabConfig.get();
|
| 291 |
+
const controlledTabs = ['debug', 'update'];
|
| 292 |
+
|
| 293 |
+
// Update visibility based on conditions
|
| 294 |
+
const updatedTabs = currentConfig.userTabs.map((tab: TabConfig) => {
|
| 295 |
+
if (controlledTabs.includes(tab.id)) {
|
| 296 |
+
return {
|
| 297 |
+
...tab,
|
| 298 |
+
visible: tab.id === 'debug' ? metrics.cpu.usage > 80 : hasUpdate,
|
| 299 |
+
};
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
return tab;
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
tabConfig.set({
|
| 306 |
+
...currentConfig,
|
| 307 |
+
userTabs: updatedTabs,
|
| 308 |
+
});
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
const checkInterval = setInterval(handleTabVisibility, 5000);
|
| 312 |
+
|
| 313 |
+
return () => {
|
| 314 |
+
clearInterval(checkInterval);
|
| 315 |
+
};
|
| 316 |
+
}, [metrics.cpu.usage, hasUpdate, tabConfig]);
|
| 317 |
+
|
| 318 |
+
// Effect to handle reset and initialization
|
| 319 |
+
useEffect(() => {
|
| 320 |
+
const resetToDefaults = () => {
|
| 321 |
+
console.log('TaskManagerTab: Resetting to defaults');
|
| 322 |
+
|
| 323 |
+
// Reset metrics and local state
|
| 324 |
+
setMetrics(DEFAULT_METRICS_STATE);
|
| 325 |
+
setMetricsHistory(DEFAULT_METRICS_HISTORY);
|
| 326 |
+
setEnergySaverMode(false);
|
| 327 |
+
setAutoEnergySaver(false);
|
| 328 |
+
setEnergySavings({
|
| 329 |
+
updatesReduced: 0,
|
| 330 |
+
timeInSaverMode: 0,
|
| 331 |
+
estimatedEnergySaved: 0,
|
| 332 |
+
});
|
| 333 |
+
setSelectedProfile(POWER_PROFILES[1]);
|
| 334 |
+
setAlerts([]);
|
| 335 |
+
saverModeStartTime.current = null;
|
| 336 |
+
|
| 337 |
+
// Reset tab configuration to ensure proper visibility
|
| 338 |
+
const defaultConfig = resetTabConfiguration();
|
| 339 |
+
console.log('TaskManagerTab: Reset tab configuration:', defaultConfig);
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
// Listen for both storage changes and custom reset event
|
| 343 |
+
const handleReset = (event: Event | StorageEvent) => {
|
| 344 |
+
if (event instanceof StorageEvent) {
|
| 345 |
+
if (event.key === 'tabConfiguration' && event.newValue === null) {
|
| 346 |
+
resetToDefaults();
|
| 347 |
+
}
|
| 348 |
+
} else if (event instanceof CustomEvent && event.type === 'tabConfigReset') {
|
| 349 |
+
resetToDefaults();
|
| 350 |
+
}
|
| 351 |
+
};
|
| 352 |
+
|
| 353 |
+
// Initial setup
|
| 354 |
+
const initializeTab = async () => {
|
| 355 |
+
try {
|
| 356 |
+
// Load saved preferences
|
| 357 |
+
const savedEnergySaver = localStorage.getItem('energySaverMode');
|
| 358 |
+
const savedAutoSaver = localStorage.getItem('autoEnergySaver');
|
| 359 |
+
const savedProfile = localStorage.getItem('selectedProfile');
|
| 360 |
+
|
| 361 |
+
if (savedEnergySaver) {
|
| 362 |
+
setEnergySaverMode(JSON.parse(savedEnergySaver));
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
if (savedAutoSaver) {
|
| 366 |
+
setAutoEnergySaver(JSON.parse(savedAutoSaver));
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
if (savedProfile) {
|
| 370 |
+
const profile = POWER_PROFILES.find((p) => p.name === savedProfile);
|
| 371 |
+
|
| 372 |
+
if (profile) {
|
| 373 |
+
setSelectedProfile(profile);
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
await updateMetrics();
|
| 378 |
+
} catch (error) {
|
| 379 |
+
console.error('Failed to initialize TaskManagerTab:', error);
|
| 380 |
+
resetToDefaults();
|
| 381 |
+
}
|
| 382 |
+
};
|
| 383 |
+
|
| 384 |
+
window.addEventListener('storage', handleReset);
|
| 385 |
+
window.addEventListener('tabConfigReset', handleReset);
|
| 386 |
+
initializeTab();
|
| 387 |
+
|
| 388 |
+
return () => {
|
| 389 |
+
window.removeEventListener('storage', handleReset);
|
| 390 |
+
window.removeEventListener('tabConfigReset', handleReset);
|
| 391 |
+
};
|
| 392 |
+
}, []);
|
| 393 |
+
|
| 394 |
+
// Get detailed performance metrics
|
| 395 |
+
const getPerformanceMetrics = async (): Promise<Partial<SystemMetrics['performance']>> => {
|
| 396 |
+
try {
|
| 397 |
+
// Get FPS
|
| 398 |
+
const fps = await measureFrameRate();
|
| 399 |
+
|
| 400 |
+
// Get page load metrics
|
| 401 |
+
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
| 402 |
+
const pageLoad = navigation.loadEventEnd - navigation.startTime;
|
| 403 |
+
const domReady = navigation.domContentLoadedEventEnd - navigation.startTime;
|
| 404 |
+
|
| 405 |
+
// Get resource metrics
|
| 406 |
+
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
| 407 |
+
const resourceMetrics = {
|
| 408 |
+
total: resources.length,
|
| 409 |
+
size: resources.reduce((total, r) => total + (r.transferSize || 0), 0),
|
| 410 |
+
loadTime: Math.max(0, ...resources.map((r) => r.duration)),
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
// Get Web Vitals
|
| 414 |
+
const ttfb = navigation.responseStart - navigation.requestStart;
|
| 415 |
+
const paintEntries = performance.getEntriesByType('paint');
|
| 416 |
+
const fcp = paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0;
|
| 417 |
+
const lcpEntry = await getLargestContentfulPaint();
|
| 418 |
+
|
| 419 |
+
return {
|
| 420 |
+
fps,
|
| 421 |
+
pageLoad,
|
| 422 |
+
domReady,
|
| 423 |
+
resources: resourceMetrics,
|
| 424 |
+
timing: {
|
| 425 |
+
ttfb,
|
| 426 |
+
fcp,
|
| 427 |
+
lcp: lcpEntry?.startTime || 0,
|
| 428 |
+
},
|
| 429 |
+
};
|
| 430 |
+
} catch (error) {
|
| 431 |
+
console.error('Failed to get performance metrics:', error);
|
| 432 |
+
return {};
|
| 433 |
+
}
|
| 434 |
+
};
|
| 435 |
+
|
| 436 |
+
// Single useEffect for metrics updates
|
| 437 |
+
useEffect(() => {
|
| 438 |
+
let isComponentMounted = true;
|
| 439 |
+
|
| 440 |
+
const updateMetricsWrapper = async () => {
|
| 441 |
+
if (!isComponentMounted) {
|
| 442 |
+
return;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
try {
|
| 446 |
+
await updateMetrics();
|
| 447 |
+
} catch (error) {
|
| 448 |
+
console.error('Failed to update metrics:', error);
|
| 449 |
+
}
|
| 450 |
+
};
|
| 451 |
+
|
| 452 |
+
// Initial update
|
| 453 |
+
updateMetricsWrapper();
|
| 454 |
+
|
| 455 |
+
// Set up interval with immediate assignment
|
| 456 |
+
const metricsInterval = setInterval(
|
| 457 |
+
updateMetricsWrapper,
|
| 458 |
+
energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
|
| 459 |
+
);
|
| 460 |
+
|
| 461 |
+
// Cleanup function
|
| 462 |
+
return () => {
|
| 463 |
+
isComponentMounted = false;
|
| 464 |
+
clearInterval(metricsInterval);
|
| 465 |
+
};
|
| 466 |
+
}, [energySaverMode]); // Only depend on energySaverMode
|
| 467 |
+
|
| 468 |
+
// Handle energy saver mode changes
|
| 469 |
+
const handleEnergySaverChange = (checked: boolean) => {
|
| 470 |
+
setEnergySaverMode(checked);
|
| 471 |
+
localStorage.setItem('energySaverMode', JSON.stringify(checked));
|
| 472 |
+
toast.success(checked ? 'Energy Saver mode enabled' : 'Energy Saver mode disabled');
|
| 473 |
+
};
|
| 474 |
+
|
| 475 |
+
// Handle auto energy saver changes
|
| 476 |
+
const handleAutoEnergySaverChange = (checked: boolean) => {
|
| 477 |
+
setAutoEnergySaver(checked);
|
| 478 |
+
localStorage.setItem('autoEnergySaver', JSON.stringify(checked));
|
| 479 |
+
toast.success(checked ? 'Auto Energy Saver enabled' : 'Auto Energy Saver disabled');
|
| 480 |
+
|
| 481 |
+
if (!checked) {
|
| 482 |
+
// When disabling auto mode, also disable energy saver mode
|
| 483 |
+
setEnergySaverMode(false);
|
| 484 |
+
localStorage.setItem('energySaverMode', 'false');
|
| 485 |
+
}
|
| 486 |
+
};
|
| 487 |
+
|
| 488 |
+
// Update energy savings calculation
|
| 489 |
+
const updateEnergySavings = useCallback(() => {
|
| 490 |
+
if (!energySaverMode) {
|
| 491 |
+
saverModeStartTime.current = null;
|
| 492 |
+
setEnergySavings({
|
| 493 |
+
updatesReduced: 0,
|
| 494 |
+
timeInSaverMode: 0,
|
| 495 |
+
estimatedEnergySaved: 0,
|
| 496 |
+
});
|
| 497 |
+
|
| 498 |
+
return;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
if (!saverModeStartTime.current) {
|
| 502 |
+
saverModeStartTime.current = Date.now();
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
const timeInSaverMode = Math.max(0, (Date.now() - (saverModeStartTime.current || Date.now())) / 1000);
|
| 506 |
+
|
| 507 |
+
const normalUpdatesPerMinute = 60 / (UPDATE_INTERVALS.normal.metrics / 1000);
|
| 508 |
+
const saverUpdatesPerMinute = 60 / (UPDATE_INTERVALS.energySaver.metrics / 1000);
|
| 509 |
+
const updatesReduced = Math.floor((normalUpdatesPerMinute - saverUpdatesPerMinute) * (timeInSaverMode / 60));
|
| 510 |
+
|
| 511 |
+
const energyPerUpdate = ENERGY_COSTS.update;
|
| 512 |
+
const energySaved = (updatesReduced * energyPerUpdate) / 3600;
|
| 513 |
+
|
| 514 |
+
setEnergySavings({
|
| 515 |
+
updatesReduced,
|
| 516 |
+
timeInSaverMode,
|
| 517 |
+
estimatedEnergySaved: energySaved,
|
| 518 |
+
});
|
| 519 |
+
}, [energySaverMode]);
|
| 520 |
+
|
| 521 |
+
// Add interval for energy savings updates
|
| 522 |
+
useEffect(() => {
|
| 523 |
+
const interval = setInterval(updateEnergySavings, 1000);
|
| 524 |
+
return () => clearInterval(interval);
|
| 525 |
+
}, [updateEnergySavings]);
|
| 526 |
+
|
| 527 |
+
// Measure frame rate
|
| 528 |
+
const measureFrameRate = async (): Promise<number> => {
|
| 529 |
+
return new Promise((resolve) => {
|
| 530 |
+
const frameCount = { value: 0 };
|
| 531 |
+
const startTime = performance.now();
|
| 532 |
+
|
| 533 |
+
const countFrame = (time: number) => {
|
| 534 |
+
frameCount.value++;
|
| 535 |
+
|
| 536 |
+
if (time - startTime >= 1000) {
|
| 537 |
+
resolve(Math.round((frameCount.value * 1000) / (time - startTime)));
|
| 538 |
+
} else {
|
| 539 |
+
requestAnimationFrame(countFrame);
|
| 540 |
+
}
|
| 541 |
+
};
|
| 542 |
+
|
| 543 |
+
requestAnimationFrame(countFrame);
|
| 544 |
+
});
|
| 545 |
+
};
|
| 546 |
+
|
| 547 |
+
// Get Largest Contentful Paint
|
| 548 |
+
const getLargestContentfulPaint = async (): Promise<PerformanceEntry | undefined> => {
|
| 549 |
+
return new Promise((resolve) => {
|
| 550 |
+
new PerformanceObserver((list) => {
|
| 551 |
+
const entries = list.getEntries();
|
| 552 |
+
resolve(entries[entries.length - 1]);
|
| 553 |
+
}).observe({ entryTypes: ['largest-contentful-paint'] });
|
| 554 |
+
|
| 555 |
+
// Resolve after 3 seconds if no LCP entry is found
|
| 556 |
+
setTimeout(() => resolve(undefined), 3000);
|
| 557 |
+
});
|
| 558 |
+
};
|
| 559 |
+
|
| 560 |
+
// Analyze system health
|
| 561 |
+
const analyzeSystemHealth = (currentMetrics: SystemMetrics): SystemMetrics['health'] => {
|
| 562 |
+
const issues: string[] = [];
|
| 563 |
+
const suggestions: string[] = [];
|
| 564 |
+
let score = 100;
|
| 565 |
+
|
| 566 |
+
// CPU analysis
|
| 567 |
+
if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
|
| 568 |
+
score -= 30;
|
| 569 |
+
issues.push('Critical CPU usage');
|
| 570 |
+
suggestions.push('Consider closing resource-intensive applications');
|
| 571 |
+
} else if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.warning) {
|
| 572 |
+
score -= 15;
|
| 573 |
+
issues.push('High CPU usage');
|
| 574 |
+
suggestions.push('Monitor system processes for unusual activity');
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
// Memory analysis
|
| 578 |
+
if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
|
| 579 |
+
score -= 30;
|
| 580 |
+
issues.push('Critical memory usage');
|
| 581 |
+
suggestions.push('Close unused applications to free up memory');
|
| 582 |
+
} else if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.warning) {
|
| 583 |
+
score -= 15;
|
| 584 |
+
issues.push('High memory usage');
|
| 585 |
+
suggestions.push('Consider freeing up memory by closing background applications');
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// Performance analysis
|
| 589 |
+
if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
|
| 590 |
+
score -= 20;
|
| 591 |
+
issues.push('Very low frame rate');
|
| 592 |
+
suggestions.push('Disable animations or switch to power saver mode');
|
| 593 |
+
} else if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.warning) {
|
| 594 |
+
score -= 10;
|
| 595 |
+
issues.push('Low frame rate');
|
| 596 |
+
suggestions.push('Consider reducing visual effects');
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
// Battery analysis
|
| 600 |
+
if (currentMetrics.battery && !currentMetrics.battery.charging && currentMetrics.battery.level < 20) {
|
| 601 |
+
score -= 10;
|
| 602 |
+
issues.push('Low battery');
|
| 603 |
+
suggestions.push('Connect to power source or enable power saver mode');
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
return {
|
| 607 |
+
score: Math.max(0, score),
|
| 608 |
+
issues,
|
| 609 |
+
suggestions,
|
| 610 |
+
};
|
| 611 |
+
};
|
| 612 |
+
|
| 613 |
+
// Update metrics with enhanced data
|
| 614 |
+
const updateMetrics = async () => {
|
| 615 |
+
try {
|
| 616 |
+
// Get memory info using Performance API
|
| 617 |
+
const memory = performance.memory || {
|
| 618 |
+
jsHeapSizeLimit: 0,
|
| 619 |
+
totalJSHeapSize: 0,
|
| 620 |
+
usedJSHeapSize: 0,
|
| 621 |
+
};
|
| 622 |
+
const totalMem = memory.totalJSHeapSize / (1024 * 1024);
|
| 623 |
+
const usedMem = memory.usedJSHeapSize / (1024 * 1024);
|
| 624 |
+
const memPercentage = (usedMem / totalMem) * 100;
|
| 625 |
+
|
| 626 |
+
// Get CPU usage using Performance API
|
| 627 |
+
const cpuUsage = await getCPUUsage();
|
| 628 |
+
|
| 629 |
+
// Get battery info
|
| 630 |
+
let batteryInfo: SystemMetrics['battery'] | undefined;
|
| 631 |
+
|
| 632 |
+
try {
|
| 633 |
+
const battery = await navigator.getBattery();
|
| 634 |
+
batteryInfo = {
|
| 635 |
+
level: battery.level * 100,
|
| 636 |
+
charging: battery.charging,
|
| 637 |
+
timeRemaining: battery.charging ? battery.chargingTime : battery.dischargingTime,
|
| 638 |
+
};
|
| 639 |
+
} catch {
|
| 640 |
+
console.log('Battery API not available');
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
// Get network info using Network Information API
|
| 644 |
+
const connection =
|
| 645 |
+
(navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
|
| 646 |
+
const networkInfo = {
|
| 647 |
+
downlink: connection?.downlink || 0,
|
| 648 |
+
uplink: connection?.uplink,
|
| 649 |
+
latency: connection?.rtt || 0,
|
| 650 |
+
type: connection?.type || 'unknown',
|
| 651 |
+
activeConnections: connection?.activeConnections,
|
| 652 |
+
bytesReceived: connection?.bytesReceived || 0,
|
| 653 |
+
bytesSent: connection?.bytesSent || 0,
|
| 654 |
+
};
|
| 655 |
+
|
| 656 |
+
// Get enhanced performance metrics
|
| 657 |
+
const performanceMetrics = await getPerformanceMetrics();
|
| 658 |
+
|
| 659 |
+
const metrics: SystemMetrics = {
|
| 660 |
+
cpu: { usage: cpuUsage, cores: [], temperature: undefined, frequency: undefined },
|
| 661 |
+
memory: {
|
| 662 |
+
used: Math.round(usedMem),
|
| 663 |
+
total: Math.round(totalMem),
|
| 664 |
+
percentage: Math.round(memPercentage),
|
| 665 |
+
heap: {
|
| 666 |
+
used: Math.round(usedMem),
|
| 667 |
+
total: Math.round(totalMem),
|
| 668 |
+
limit: Math.round(totalMem),
|
| 669 |
+
},
|
| 670 |
+
},
|
| 671 |
+
uptime: performance.now() / 1000,
|
| 672 |
+
battery: batteryInfo,
|
| 673 |
+
network: networkInfo,
|
| 674 |
+
performance: performanceMetrics as SystemMetrics['performance'],
|
| 675 |
+
health: { score: 0, issues: [], suggestions: [] },
|
| 676 |
+
};
|
| 677 |
+
|
| 678 |
+
// Analyze system health
|
| 679 |
+
metrics.health = analyzeSystemHealth(metrics);
|
| 680 |
+
|
| 681 |
+
// Check for alerts
|
| 682 |
+
checkPerformanceAlerts(metrics);
|
| 683 |
+
|
| 684 |
+
setMetrics(metrics);
|
| 685 |
+
|
| 686 |
+
// Update metrics history
|
| 687 |
+
const now = new Date().toLocaleTimeString();
|
| 688 |
+
setMetricsHistory((prev) => {
|
| 689 |
+
const timestamps = [...prev.timestamps, now].slice(-MAX_HISTORY_POINTS);
|
| 690 |
+
const cpu = [...prev.cpu, metrics.cpu.usage].slice(-MAX_HISTORY_POINTS);
|
| 691 |
+
const memory = [...prev.memory, metrics.memory.percentage].slice(-MAX_HISTORY_POINTS);
|
| 692 |
+
const battery = [...prev.battery, batteryInfo?.level || 0].slice(-MAX_HISTORY_POINTS);
|
| 693 |
+
const network = [...prev.network, networkInfo.downlink].slice(-MAX_HISTORY_POINTS);
|
| 694 |
+
|
| 695 |
+
return { timestamps, cpu, memory, battery, network };
|
| 696 |
+
});
|
| 697 |
+
} catch (error) {
|
| 698 |
+
console.error('Failed to update system metrics:', error);
|
| 699 |
+
}
|
| 700 |
+
};
|
| 701 |
+
|
| 702 |
+
// Get real CPU usage using Performance API
|
| 703 |
+
const getCPUUsage = async (): Promise<number> => {
|
| 704 |
+
try {
|
| 705 |
+
const t0 = performance.now();
|
| 706 |
+
|
| 707 |
+
// Create some actual work to measure and use the result
|
| 708 |
+
let result = 0;
|
| 709 |
+
|
| 710 |
+
for (let i = 0; i < 10000; i++) {
|
| 711 |
+
result += Math.random();
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
// Use result to prevent optimization
|
| 715 |
+
if (result < 0) {
|
| 716 |
+
console.log('Unexpected negative result');
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
const t1 = performance.now();
|
| 720 |
+
const timeTaken = t1 - t0;
|
| 721 |
+
|
| 722 |
+
/*
|
| 723 |
+
* Normalize to percentage (0-100)
|
| 724 |
+
* Lower time = higher CPU availability
|
| 725 |
+
*/
|
| 726 |
+
const maxExpectedTime = 50; // baseline in ms
|
| 727 |
+
const cpuAvailability = Math.max(0, Math.min(100, ((maxExpectedTime - timeTaken) / maxExpectedTime) * 100));
|
| 728 |
+
|
| 729 |
+
return 100 - cpuAvailability; // Convert availability to usage
|
| 730 |
+
} catch (error) {
|
| 731 |
+
console.error('Failed to get CPU usage:', error);
|
| 732 |
+
return 0;
|
| 733 |
+
}
|
| 734 |
+
};
|
| 735 |
+
|
| 736 |
+
// Add network change listener
|
| 737 |
+
useEffect(() => {
|
| 738 |
+
const connection =
|
| 739 |
+
(navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
|
| 740 |
+
|
| 741 |
+
if (!connection) {
|
| 742 |
+
return;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
const updateNetworkInfo = () => {
|
| 746 |
+
setMetrics((prev) => ({
|
| 747 |
+
...prev,
|
| 748 |
+
network: {
|
| 749 |
+
downlink: connection.downlink || 0,
|
| 750 |
+
latency: connection.rtt || 0,
|
| 751 |
+
type: connection.type || 'unknown',
|
| 752 |
+
bytesReceived: connection.bytesReceived || 0,
|
| 753 |
+
bytesSent: connection.bytesSent || 0,
|
| 754 |
+
},
|
| 755 |
+
}));
|
| 756 |
+
};
|
| 757 |
+
|
| 758 |
+
connection.addEventListener('change', updateNetworkInfo);
|
| 759 |
+
|
| 760 |
+
// eslint-disable-next-line consistent-return
|
| 761 |
+
return () => connection.removeEventListener('change', updateNetworkInfo);
|
| 762 |
+
}, []);
|
| 763 |
+
|
| 764 |
+
// Remove all animation and process monitoring
|
| 765 |
+
useEffect(() => {
|
| 766 |
+
const metricsInterval = setInterval(
|
| 767 |
+
() => {
|
| 768 |
+
if (!energySaverMode) {
|
| 769 |
+
updateMetrics();
|
| 770 |
+
}
|
| 771 |
+
},
|
| 772 |
+
energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
|
| 773 |
+
);
|
| 774 |
+
|
| 775 |
+
return () => {
|
| 776 |
+
clearInterval(metricsInterval);
|
| 777 |
+
};
|
| 778 |
+
}, [energySaverMode]);
|
| 779 |
+
|
| 780 |
+
const getUsageColor = (usage: number): string => {
|
| 781 |
+
if (usage > 80) {
|
| 782 |
+
return 'text-red-500';
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
if (usage > 50) {
|
| 786 |
+
return 'text-yellow-500';
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
return 'text-gray-500';
|
| 790 |
+
};
|
| 791 |
+
|
| 792 |
+
const renderUsageGraph = (data: number[], label: string, color: string) => {
|
| 793 |
+
const chartData = {
|
| 794 |
+
labels: metricsHistory.timestamps,
|
| 795 |
+
datasets: [
|
| 796 |
+
{
|
| 797 |
+
label,
|
| 798 |
+
data,
|
| 799 |
+
borderColor: color,
|
| 800 |
+
fill: false,
|
| 801 |
+
tension: 0.4,
|
| 802 |
+
},
|
| 803 |
+
],
|
| 804 |
+
};
|
| 805 |
+
|
| 806 |
+
const options = {
|
| 807 |
+
responsive: true,
|
| 808 |
+
maintainAspectRatio: false,
|
| 809 |
+
scales: {
|
| 810 |
+
y: {
|
| 811 |
+
beginAtZero: true,
|
| 812 |
+
max: 100,
|
| 813 |
+
grid: {
|
| 814 |
+
color: 'rgba(255, 255, 255, 0.1)',
|
| 815 |
+
},
|
| 816 |
+
},
|
| 817 |
+
x: {
|
| 818 |
+
grid: {
|
| 819 |
+
display: false,
|
| 820 |
+
},
|
| 821 |
+
},
|
| 822 |
+
},
|
| 823 |
+
plugins: {
|
| 824 |
+
legend: {
|
| 825 |
+
display: false,
|
| 826 |
+
},
|
| 827 |
+
},
|
| 828 |
+
animation: {
|
| 829 |
+
duration: 0,
|
| 830 |
+
} as const,
|
| 831 |
+
};
|
| 832 |
+
|
| 833 |
+
return (
|
| 834 |
+
<div className="h-32">
|
| 835 |
+
<Line data={chartData} options={options} />
|
| 836 |
+
</div>
|
| 837 |
+
);
|
| 838 |
+
};
|
| 839 |
+
|
| 840 |
+
useEffect((): (() => void) | undefined => {
|
| 841 |
+
if (!autoEnergySaver) {
|
| 842 |
+
// If auto mode is disabled, clear any forced energy saver state
|
| 843 |
+
setEnergySaverMode(false);
|
| 844 |
+
return undefined;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
const checkBatteryStatus = async () => {
|
| 848 |
+
try {
|
| 849 |
+
const battery = await navigator.getBattery();
|
| 850 |
+
const shouldEnableSaver = !battery.charging && battery.level * 100 <= BATTERY_THRESHOLD;
|
| 851 |
+
setEnergySaverMode(shouldEnableSaver);
|
| 852 |
+
} catch {
|
| 853 |
+
console.log('Battery API not available');
|
| 854 |
+
}
|
| 855 |
+
};
|
| 856 |
+
|
| 857 |
+
checkBatteryStatus();
|
| 858 |
+
|
| 859 |
+
const batteryCheckInterval = setInterval(checkBatteryStatus, 60000);
|
| 860 |
+
|
| 861 |
+
return () => clearInterval(batteryCheckInterval);
|
| 862 |
+
}, [autoEnergySaver]);
|
| 863 |
+
|
| 864 |
+
// Check for performance alerts
|
| 865 |
+
const checkPerformanceAlerts = (currentMetrics: SystemMetrics) => {
|
| 866 |
+
const newAlerts: PerformanceAlert[] = [];
|
| 867 |
+
|
| 868 |
+
// CPU alert
|
| 869 |
+
if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
|
| 870 |
+
newAlerts.push({
|
| 871 |
+
type: 'error',
|
| 872 |
+
message: 'Critical CPU usage detected',
|
| 873 |
+
timestamp: Date.now(),
|
| 874 |
+
metric: 'cpu',
|
| 875 |
+
threshold: PERFORMANCE_THRESHOLDS.cpu.critical,
|
| 876 |
+
value: currentMetrics.cpu.usage,
|
| 877 |
+
});
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
// Memory alert
|
| 881 |
+
if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
|
| 882 |
+
newAlerts.push({
|
| 883 |
+
type: 'error',
|
| 884 |
+
message: 'Critical memory usage detected',
|
| 885 |
+
timestamp: Date.now(),
|
| 886 |
+
metric: 'memory',
|
| 887 |
+
threshold: PERFORMANCE_THRESHOLDS.memory.critical,
|
| 888 |
+
value: currentMetrics.memory.percentage,
|
| 889 |
+
});
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
// Performance alert
|
| 893 |
+
if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
|
| 894 |
+
newAlerts.push({
|
| 895 |
+
type: 'warning',
|
| 896 |
+
message: 'Very low frame rate detected',
|
| 897 |
+
timestamp: Date.now(),
|
| 898 |
+
metric: 'fps',
|
| 899 |
+
threshold: PERFORMANCE_THRESHOLDS.fps.critical,
|
| 900 |
+
value: currentMetrics.performance.fps,
|
| 901 |
+
});
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
if (newAlerts.length > 0) {
|
| 905 |
+
setAlerts((prev) => [...prev, ...newAlerts]);
|
| 906 |
+
newAlerts.forEach((alert) => {
|
| 907 |
+
toast.warning(alert.message);
|
| 908 |
+
});
|
| 909 |
+
}
|
| 910 |
+
};
|
| 911 |
+
|
| 912 |
+
return (
|
| 913 |
+
<div className="flex flex-col gap-6">
|
| 914 |
+
{/* Power Profile Selection */}
|
| 915 |
+
<div className="flex flex-col gap-4">
|
| 916 |
+
<div className="flex items-center justify-between">
|
| 917 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Power Management</h3>
|
| 918 |
+
<div className="flex items-center gap-4">
|
| 919 |
+
<div className="flex items-center gap-2">
|
| 920 |
+
<input
|
| 921 |
+
type="checkbox"
|
| 922 |
+
id="autoEnergySaver"
|
| 923 |
+
checked={autoEnergySaver}
|
| 924 |
+
onChange={(e) => handleAutoEnergySaverChange(e.target.checked)}
|
| 925 |
+
className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700"
|
| 926 |
+
/>
|
| 927 |
+
<div className="i-ph:gauge-duotone w-4 h-4 text-bolt-elements-textSecondary" />
|
| 928 |
+
<label htmlFor="autoEnergySaver" className="text-sm text-bolt-elements-textSecondary">
|
| 929 |
+
Auto Energy Saver
|
| 930 |
+
</label>
|
| 931 |
+
</div>
|
| 932 |
+
<div className="flex items-center gap-2">
|
| 933 |
+
<input
|
| 934 |
+
type="checkbox"
|
| 935 |
+
id="energySaver"
|
| 936 |
+
checked={energySaverMode}
|
| 937 |
+
onChange={(e) => !autoEnergySaver && handleEnergySaverChange(e.target.checked)}
|
| 938 |
+
disabled={autoEnergySaver}
|
| 939 |
+
className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700 disabled:opacity-50"
|
| 940 |
+
/>
|
| 941 |
+
<div className="i-ph:leaf-duotone w-4 h-4 text-bolt-elements-textSecondary" />
|
| 942 |
+
<label
|
| 943 |
+
htmlFor="energySaver"
|
| 944 |
+
className={classNames('text-sm text-bolt-elements-textSecondary', { 'opacity-50': autoEnergySaver })}
|
| 945 |
+
>
|
| 946 |
+
Energy Saver
|
| 947 |
+
{energySaverMode && <span className="ml-2 text-xs text-bolt-elements-textSecondary">Active</span>}
|
| 948 |
+
</label>
|
| 949 |
+
</div>
|
| 950 |
+
<div className="relative">
|
| 951 |
+
<select
|
| 952 |
+
value={selectedProfile.name}
|
| 953 |
+
onChange={(e) => {
|
| 954 |
+
const profile = POWER_PROFILES.find((p) => p.name === e.target.value);
|
| 955 |
+
|
| 956 |
+
if (profile) {
|
| 957 |
+
setSelectedProfile(profile);
|
| 958 |
+
toast.success(`Switched to ${profile.name} power profile`);
|
| 959 |
+
}
|
| 960 |
+
}}
|
| 961 |
+
className="pl-8 pr-8 py-1.5 rounded-md bg-bolt-background-secondary dark:bg-[#1E1E1E] border border-bolt-border dark:border-bolt-borderDark text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:border-bolt-action-primary dark:hover:border-bolt-action-primary focus:outline-none focus:ring-1 focus:ring-bolt-action-primary appearance-none min-w-[160px] cursor-pointer transition-colors duration-150"
|
| 962 |
+
style={{ WebkitAppearance: 'none', MozAppearance: 'none' }}
|
| 963 |
+
>
|
| 964 |
+
{POWER_PROFILES.map((profile) => (
|
| 965 |
+
<option
|
| 966 |
+
key={profile.name}
|
| 967 |
+
value={profile.name}
|
| 968 |
+
className="py-2 px-3 bg-bolt-background-secondary dark:bg-[#1E1E1E] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:bg-bolt-background-tertiary dark:hover:bg-bolt-backgroundDark-tertiary cursor-pointer"
|
| 969 |
+
>
|
| 970 |
+
{profile.name}
|
| 971 |
+
</option>
|
| 972 |
+
))}
|
| 973 |
+
</select>
|
| 974 |
+
<div className="absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
| 975 |
+
<div
|
| 976 |
+
className={classNames('w-4 h-4 text-bolt-elements-textSecondary', {
|
| 977 |
+
'i-ph:lightning-fill text-yellow-500': selectedProfile.name === 'Performance',
|
| 978 |
+
'i-ph:scales-fill text-blue-500': selectedProfile.name === 'Balanced',
|
| 979 |
+
'i-ph:leaf-fill text-green-500': selectedProfile.name === 'Energy Saver',
|
| 980 |
+
})}
|
| 981 |
+
/>
|
| 982 |
+
</div>
|
| 983 |
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
| 984 |
+
<div className="i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75" />
|
| 985 |
+
</div>
|
| 986 |
+
</div>
|
| 987 |
+
</div>
|
| 988 |
+
</div>
|
| 989 |
+
<div className="text-sm text-bolt-elements-textSecondary">{selectedProfile.description}</div>
|
| 990 |
+
</div>
|
| 991 |
+
|
| 992 |
+
{/* System Health Score */}
|
| 993 |
+
<div className="flex flex-col gap-4">
|
| 994 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">System Health</h3>
|
| 995 |
+
<div className="grid grid-cols-1 gap-4">
|
| 996 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 997 |
+
<div className="flex items-center justify-between">
|
| 998 |
+
<span className="text-sm text-bolt-elements-textSecondary">Health Score</span>
|
| 999 |
+
<span
|
| 1000 |
+
className={classNames('text-lg font-medium', {
|
| 1001 |
+
'text-green-500': metrics.health.score >= 80,
|
| 1002 |
+
'text-yellow-500': metrics.health.score >= 60 && metrics.health.score < 80,
|
| 1003 |
+
'text-red-500': metrics.health.score < 60,
|
| 1004 |
+
})}
|
| 1005 |
+
>
|
| 1006 |
+
{metrics.health.score}%
|
| 1007 |
+
</span>
|
| 1008 |
+
</div>
|
| 1009 |
+
{metrics.health.issues.length > 0 && (
|
| 1010 |
+
<div className="mt-2">
|
| 1011 |
+
<div className="text-sm font-medium text-bolt-elements-textSecondary mb-1">Issues:</div>
|
| 1012 |
+
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
|
| 1013 |
+
{metrics.health.issues.map((issue, index) => (
|
| 1014 |
+
<li key={index} className="flex items-center gap-2">
|
| 1015 |
+
<div className="i-ph:warning-circle-fill text-yellow-500 w-4 h-4" />
|
| 1016 |
+
{issue}
|
| 1017 |
+
</li>
|
| 1018 |
+
))}
|
| 1019 |
+
</ul>
|
| 1020 |
+
</div>
|
| 1021 |
+
)}
|
| 1022 |
+
{metrics.health.suggestions.length > 0 && (
|
| 1023 |
+
<div className="mt-2">
|
| 1024 |
+
<div className="text-sm font-medium text-bolt-elements-textSecondary mb-1">Suggestions:</div>
|
| 1025 |
+
<ul className="text-sm text-bolt-elements-textSecondary space-y-1">
|
| 1026 |
+
{metrics.health.suggestions.map((suggestion, index) => (
|
| 1027 |
+
<li key={index} className="flex items-center gap-2">
|
| 1028 |
+
<div className="i-ph:lightbulb-fill text-purple-500 w-4 h-4" />
|
| 1029 |
+
{suggestion}
|
| 1030 |
+
</li>
|
| 1031 |
+
))}
|
| 1032 |
+
</ul>
|
| 1033 |
+
</div>
|
| 1034 |
+
)}
|
| 1035 |
+
</div>
|
| 1036 |
+
</div>
|
| 1037 |
+
</div>
|
| 1038 |
+
|
| 1039 |
+
{/* System Metrics */}
|
| 1040 |
+
<div className="flex flex-col gap-4">
|
| 1041 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">System Metrics</h3>
|
| 1042 |
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
| 1043 |
+
{/* CPU Usage */}
|
| 1044 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1045 |
+
<div className="flex items-center justify-between">
|
| 1046 |
+
<span className="text-sm text-bolt-elements-textSecondary">CPU Usage</span>
|
| 1047 |
+
<span className={classNames('text-sm font-medium', getUsageColor(metrics.cpu.usage))}>
|
| 1048 |
+
{Math.round(metrics.cpu.usage)}%
|
| 1049 |
+
</span>
|
| 1050 |
+
</div>
|
| 1051 |
+
{renderUsageGraph(metricsHistory.cpu, 'CPU', '#9333ea')}
|
| 1052 |
+
{metrics.cpu.temperature && (
|
| 1053 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">
|
| 1054 |
+
Temperature: {metrics.cpu.temperature}°C
|
| 1055 |
+
</div>
|
| 1056 |
+
)}
|
| 1057 |
+
{metrics.cpu.frequency && (
|
| 1058 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1059 |
+
Frequency: {(metrics.cpu.frequency / 1000).toFixed(1)} GHz
|
| 1060 |
+
</div>
|
| 1061 |
+
)}
|
| 1062 |
+
</div>
|
| 1063 |
+
|
| 1064 |
+
{/* Memory Usage */}
|
| 1065 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1066 |
+
<div className="flex items-center justify-between">
|
| 1067 |
+
<span className="text-sm text-bolt-elements-textSecondary">Memory Usage</span>
|
| 1068 |
+
<span className={classNames('text-sm font-medium', getUsageColor(metrics.memory.percentage))}>
|
| 1069 |
+
{Math.round(metrics.memory.percentage)}%
|
| 1070 |
+
</span>
|
| 1071 |
+
</div>
|
| 1072 |
+
{renderUsageGraph(metricsHistory.memory, 'Memory', '#2563eb')}
|
| 1073 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">
|
| 1074 |
+
Used: {formatBytes(metrics.memory.used)}
|
| 1075 |
+
</div>
|
| 1076 |
+
<div className="text-xs text-bolt-elements-textSecondary">Total: {formatBytes(metrics.memory.total)}</div>
|
| 1077 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1078 |
+
Heap: {formatBytes(metrics.memory.heap.used)} / {formatBytes(metrics.memory.heap.total)}
|
| 1079 |
+
</div>
|
| 1080 |
+
</div>
|
| 1081 |
+
|
| 1082 |
+
{/* Performance */}
|
| 1083 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1084 |
+
<div className="flex items-center justify-between">
|
| 1085 |
+
<span className="text-sm text-bolt-elements-textSecondary">Performance</span>
|
| 1086 |
+
<span
|
| 1087 |
+
className={classNames('text-sm font-medium', {
|
| 1088 |
+
'text-red-500': metrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical,
|
| 1089 |
+
'text-yellow-500': metrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.warning,
|
| 1090 |
+
'text-green-500': metrics.performance.fps >= PERFORMANCE_THRESHOLDS.fps.warning,
|
| 1091 |
+
})}
|
| 1092 |
+
>
|
| 1093 |
+
{Math.round(metrics.performance.fps)} FPS
|
| 1094 |
+
</span>
|
| 1095 |
+
</div>
|
| 1096 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">
|
| 1097 |
+
Page Load: {(metrics.performance.pageLoad / 1000).toFixed(2)}s
|
| 1098 |
+
</div>
|
| 1099 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1100 |
+
DOM Ready: {(metrics.performance.domReady / 1000).toFixed(2)}s
|
| 1101 |
+
</div>
|
| 1102 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1103 |
+
TTFB: {(metrics.performance.timing.ttfb / 1000).toFixed(2)}s
|
| 1104 |
+
</div>
|
| 1105 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1106 |
+
Resources: {metrics.performance.resources.total} ({formatBytes(metrics.performance.resources.size)})
|
| 1107 |
+
</div>
|
| 1108 |
+
</div>
|
| 1109 |
+
|
| 1110 |
+
{/* Network */}
|
| 1111 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1112 |
+
<div className="flex items-center justify-between">
|
| 1113 |
+
<span className="text-sm text-bolt-elements-textSecondary">Network</span>
|
| 1114 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">
|
| 1115 |
+
{metrics.network.downlink.toFixed(1)} Mbps
|
| 1116 |
+
</span>
|
| 1117 |
+
</div>
|
| 1118 |
+
{renderUsageGraph(metricsHistory.network, 'Network', '#f59e0b')}
|
| 1119 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">Type: {metrics.network.type}</div>
|
| 1120 |
+
<div className="text-xs text-bolt-elements-textSecondary">Latency: {metrics.network.latency}ms</div>
|
| 1121 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1122 |
+
Received: {formatBytes(metrics.network.bytesReceived)}
|
| 1123 |
+
</div>
|
| 1124 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1125 |
+
Sent: {formatBytes(metrics.network.bytesSent)}
|
| 1126 |
+
</div>
|
| 1127 |
+
</div>
|
| 1128 |
+
</div>
|
| 1129 |
+
|
| 1130 |
+
{/* Battery Section */}
|
| 1131 |
+
{metrics.battery && (
|
| 1132 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1133 |
+
<div className="flex items-center justify-between">
|
| 1134 |
+
<span className="text-sm text-bolt-elements-textSecondary">Battery</span>
|
| 1135 |
+
<div className="flex items-center gap-2">
|
| 1136 |
+
{metrics.battery.charging && <div className="i-ph:lightning-fill w-4 h-4 text-bolt-action-primary" />}
|
| 1137 |
+
<span
|
| 1138 |
+
className={classNames(
|
| 1139 |
+
'text-sm font-medium',
|
| 1140 |
+
metrics.battery.level > 20 ? 'text-bolt-elements-textPrimary' : 'text-red-500',
|
| 1141 |
+
)}
|
| 1142 |
+
>
|
| 1143 |
+
{Math.round(metrics.battery.level)}%
|
| 1144 |
+
</span>
|
| 1145 |
+
</div>
|
| 1146 |
+
</div>
|
| 1147 |
+
{renderUsageGraph(metricsHistory.battery, 'Battery', '#22c55e')}
|
| 1148 |
+
{metrics.battery.timeRemaining && (
|
| 1149 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">
|
| 1150 |
+
{metrics.battery.charging ? 'Time to full: ' : 'Time remaining: '}
|
| 1151 |
+
{formatTime(metrics.battery.timeRemaining)}
|
| 1152 |
+
</div>
|
| 1153 |
+
)}
|
| 1154 |
+
{metrics.battery.temperature && (
|
| 1155 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
| 1156 |
+
Temperature: {metrics.battery.temperature}°C
|
| 1157 |
+
</div>
|
| 1158 |
+
)}
|
| 1159 |
+
{metrics.battery.cycles && (
|
| 1160 |
+
<div className="text-xs text-bolt-elements-textSecondary">Charge cycles: {metrics.battery.cycles}</div>
|
| 1161 |
+
)}
|
| 1162 |
+
{metrics.battery.health && (
|
| 1163 |
+
<div className="text-xs text-bolt-elements-textSecondary">Battery health: {metrics.battery.health}%</div>
|
| 1164 |
+
)}
|
| 1165 |
+
</div>
|
| 1166 |
+
)}
|
| 1167 |
+
|
| 1168 |
+
{/* Performance Alerts */}
|
| 1169 |
+
{alerts.length > 0 && (
|
| 1170 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1171 |
+
<div className="flex items-center justify-between">
|
| 1172 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Recent Alerts</span>
|
| 1173 |
+
<button
|
| 1174 |
+
onClick={() => setAlerts([])}
|
| 1175 |
+
className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
| 1176 |
+
>
|
| 1177 |
+
Clear All
|
| 1178 |
+
</button>
|
| 1179 |
+
</div>
|
| 1180 |
+
<div className="space-y-2">
|
| 1181 |
+
{alerts.slice(-5).map((alert, index) => (
|
| 1182 |
+
<div
|
| 1183 |
+
key={index}
|
| 1184 |
+
className={classNames('flex items-center gap-2 text-sm', {
|
| 1185 |
+
'text-red-500': alert.type === 'error',
|
| 1186 |
+
'text-yellow-500': alert.type === 'warning',
|
| 1187 |
+
'text-blue-500': alert.type === 'info',
|
| 1188 |
+
})}
|
| 1189 |
+
>
|
| 1190 |
+
<div
|
| 1191 |
+
className={classNames('w-4 h-4', {
|
| 1192 |
+
'i-ph:warning-circle-fill': alert.type === 'warning',
|
| 1193 |
+
'i-ph:x-circle-fill': alert.type === 'error',
|
| 1194 |
+
'i-ph:info-fill': alert.type === 'info',
|
| 1195 |
+
})}
|
| 1196 |
+
/>
|
| 1197 |
+
<span>{alert.message}</span>
|
| 1198 |
+
<span className="text-xs text-bolt-elements-textSecondary ml-auto">
|
| 1199 |
+
{new Date(alert.timestamp).toLocaleTimeString()}
|
| 1200 |
+
</span>
|
| 1201 |
+
</div>
|
| 1202 |
+
))}
|
| 1203 |
+
</div>
|
| 1204 |
+
</div>
|
| 1205 |
+
)}
|
| 1206 |
+
|
| 1207 |
+
{/* Energy Savings */}
|
| 1208 |
+
{energySaverMode && (
|
| 1209 |
+
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
| 1210 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Energy Savings</h4>
|
| 1211 |
+
<div className="grid grid-cols-3 gap-4">
|
| 1212 |
+
<div>
|
| 1213 |
+
<span className="text-sm text-bolt-elements-textSecondary">Updates Reduced</span>
|
| 1214 |
+
<p className="text-lg font-medium text-bolt-elements-textPrimary">{energySavings.updatesReduced}</p>
|
| 1215 |
+
</div>
|
| 1216 |
+
<div>
|
| 1217 |
+
<span className="text-sm text-bolt-elements-textSecondary">Time in Saver Mode</span>
|
| 1218 |
+
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 1219 |
+
{Math.floor(energySavings.timeInSaverMode / 60)}m {Math.floor(energySavings.timeInSaverMode % 60)}s
|
| 1220 |
+
</p>
|
| 1221 |
+
</div>
|
| 1222 |
+
<div>
|
| 1223 |
+
<span className="text-sm text-bolt-elements-textSecondary">Energy Saved</span>
|
| 1224 |
+
<p className="text-lg font-medium text-bolt-elements-textPrimary">
|
| 1225 |
+
{energySavings.estimatedEnergySaved.toFixed(2)} mWh
|
| 1226 |
+
</p>
|
| 1227 |
+
</div>
|
| 1228 |
+
</div>
|
| 1229 |
+
</div>
|
| 1230 |
+
)}
|
| 1231 |
+
</div>
|
| 1232 |
+
</div>
|
| 1233 |
+
);
|
| 1234 |
+
};
|
| 1235 |
+
|
| 1236 |
+
export default React.memo(TaskManagerTab);
|
| 1237 |
+
|
| 1238 |
+
// Helper function to format bytes
|
| 1239 |
+
const formatBytes = (bytes: number): string => {
|
| 1240 |
+
if (bytes === 0) {
|
| 1241 |
+
return '0 B';
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
const k = 1024;
|
| 1245 |
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
| 1246 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 1247 |
+
|
| 1248 |
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
| 1249 |
+
};
|
| 1250 |
+
|
| 1251 |
+
// Helper function to format time
|
| 1252 |
+
const formatTime = (seconds: number): string => {
|
| 1253 |
+
if (!isFinite(seconds) || seconds === 0) {
|
| 1254 |
+
return 'Unknown';
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
const hours = Math.floor(seconds / 3600);
|
| 1258 |
+
const minutes = Math.floor((seconds % 3600) / 60);
|
| 1259 |
+
|
| 1260 |
+
if (hours > 0) {
|
| 1261 |
+
return `${hours}h ${minutes}m`;
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
return `${minutes}m`;
|
| 1265 |
+
};
|
app/components/@settings/tabs/update/UpdateTab.tsx
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
| 4 |
+
import { logStore } from '~/lib/stores/logs';
|
| 5 |
+
import { toast } from 'react-toastify';
|
| 6 |
+
import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog';
|
| 7 |
+
import { classNames } from '~/utils/classNames';
|
| 8 |
+
import { Markdown } from '~/components/chat/Markdown';
|
| 9 |
+
|
| 10 |
+
interface UpdateProgress {
|
| 11 |
+
stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
|
| 12 |
+
message: string;
|
| 13 |
+
progress?: number;
|
| 14 |
+
error?: string;
|
| 15 |
+
details?: {
|
| 16 |
+
changedFiles?: string[];
|
| 17 |
+
additions?: number;
|
| 18 |
+
deletions?: number;
|
| 19 |
+
commitMessages?: string[];
|
| 20 |
+
totalSize?: string;
|
| 21 |
+
currentCommit?: string;
|
| 22 |
+
remoteCommit?: string;
|
| 23 |
+
updateReady?: boolean;
|
| 24 |
+
changelog?: string;
|
| 25 |
+
compareUrl?: string;
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
interface UpdateSettings {
|
| 30 |
+
autoUpdate: boolean;
|
| 31 |
+
notifyInApp: boolean;
|
| 32 |
+
checkInterval: number;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const ProgressBar = ({ progress }: { progress: number }) => (
|
| 36 |
+
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
| 37 |
+
<motion.div
|
| 38 |
+
className="h-full bg-blue-500"
|
| 39 |
+
initial={{ width: 0 }}
|
| 40 |
+
animate={{ width: `${progress}%` }}
|
| 41 |
+
transition={{ duration: 0.3 }}
|
| 42 |
+
/>
|
| 43 |
+
</div>
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
const UpdateProgressDisplay = ({ progress }: { progress: UpdateProgress }) => (
|
| 47 |
+
<div className="mt-4 space-y-2">
|
| 48 |
+
<div className="flex justify-between items-center">
|
| 49 |
+
<span className="text-sm font-medium">{progress.message}</span>
|
| 50 |
+
<span className="text-sm text-gray-500">{progress.progress}%</span>
|
| 51 |
+
</div>
|
| 52 |
+
<ProgressBar progress={progress.progress || 0} />
|
| 53 |
+
{progress.details && (
|
| 54 |
+
<div className="mt-2 text-sm text-gray-600">
|
| 55 |
+
{progress.details.changedFiles && progress.details.changedFiles.length > 0 && (
|
| 56 |
+
<div className="mt-4">
|
| 57 |
+
<div className="font-medium mb-2">Changed Files:</div>
|
| 58 |
+
<div className="space-y-2">
|
| 59 |
+
{/* Group files by type */}
|
| 60 |
+
{['Modified', 'Added', 'Deleted'].map((type) => {
|
| 61 |
+
const filesOfType = progress.details?.changedFiles?.filter((file) => file.startsWith(type)) || [];
|
| 62 |
+
|
| 63 |
+
if (filesOfType.length === 0) {
|
| 64 |
+
return null;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div key={type} className="space-y-1">
|
| 69 |
+
<div
|
| 70 |
+
className={classNames('text-sm font-medium', {
|
| 71 |
+
'text-blue-500': type === 'Modified',
|
| 72 |
+
'text-green-500': type === 'Added',
|
| 73 |
+
'text-red-500': type === 'Deleted',
|
| 74 |
+
})}
|
| 75 |
+
>
|
| 76 |
+
{type} ({filesOfType.length})
|
| 77 |
+
</div>
|
| 78 |
+
<div className="pl-4 space-y-1">
|
| 79 |
+
{filesOfType.map((file, index) => {
|
| 80 |
+
const fileName = file.split(': ')[1];
|
| 81 |
+
return (
|
| 82 |
+
<div key={index} className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
|
| 83 |
+
<div
|
| 84 |
+
className={classNames('w-4 h-4', {
|
| 85 |
+
'i-ph:pencil-simple': type === 'Modified',
|
| 86 |
+
'i-ph:plus': type === 'Added',
|
| 87 |
+
'i-ph:trash': type === 'Deleted',
|
| 88 |
+
'text-blue-500': type === 'Modified',
|
| 89 |
+
'text-green-500': type === 'Added',
|
| 90 |
+
'text-red-500': type === 'Deleted',
|
| 91 |
+
})}
|
| 92 |
+
/>
|
| 93 |
+
<span className="font-mono text-xs">{fileName}</span>
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
})}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
})}
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
{progress.details.totalSize && <div className="mt-1">Total size: {progress.details.totalSize}</div>}
|
| 105 |
+
{progress.details.additions !== undefined && progress.details.deletions !== undefined && (
|
| 106 |
+
<div className="mt-1">
|
| 107 |
+
Changes: <span className="text-green-600">+{progress.details.additions}</span>{' '}
|
| 108 |
+
<span className="text-red-600">-{progress.details.deletions}</span>
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
{progress.details.currentCommit && progress.details.remoteCommit && (
|
| 112 |
+
<div className="mt-1">
|
| 113 |
+
Updating from {progress.details.currentCommit} to {progress.details.remoteCommit}
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
</div>
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
const UpdateTab = () => {
|
| 122 |
+
const { isLatestBranch } = useSettings();
|
| 123 |
+
const [isChecking, setIsChecking] = useState(false);
|
| 124 |
+
const [error, setError] = useState<string | null>(null);
|
| 125 |
+
const [updateSettings, setUpdateSettings] = useState<UpdateSettings>(() => {
|
| 126 |
+
const stored = localStorage.getItem('update_settings');
|
| 127 |
+
return stored
|
| 128 |
+
? JSON.parse(stored)
|
| 129 |
+
: {
|
| 130 |
+
autoUpdate: false,
|
| 131 |
+
notifyInApp: true,
|
| 132 |
+
checkInterval: 24,
|
| 133 |
+
};
|
| 134 |
+
});
|
| 135 |
+
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
| 136 |
+
const [updateProgress, setUpdateProgress] = useState<UpdateProgress | null>(null);
|
| 137 |
+
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
localStorage.setItem('update_settings', JSON.stringify(updateSettings));
|
| 140 |
+
}, [updateSettings]);
|
| 141 |
+
|
| 142 |
+
const checkForUpdates = async () => {
|
| 143 |
+
console.log('Starting update check...');
|
| 144 |
+
setIsChecking(true);
|
| 145 |
+
setError(null);
|
| 146 |
+
setUpdateProgress(null);
|
| 147 |
+
|
| 148 |
+
try {
|
| 149 |
+
const branchToCheck = isLatestBranch ? 'main' : 'stable';
|
| 150 |
+
|
| 151 |
+
// Start the update check with streaming progress
|
| 152 |
+
const response = await fetch('/api/update', {
|
| 153 |
+
method: 'POST',
|
| 154 |
+
headers: {
|
| 155 |
+
'Content-Type': 'application/json',
|
| 156 |
+
},
|
| 157 |
+
body: JSON.stringify({
|
| 158 |
+
branch: branchToCheck,
|
| 159 |
+
autoUpdate: updateSettings.autoUpdate,
|
| 160 |
+
}),
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
if (!response.ok) {
|
| 164 |
+
throw new Error(`Update check failed: ${response.statusText}`);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const reader = response.body?.getReader();
|
| 168 |
+
|
| 169 |
+
if (!reader) {
|
| 170 |
+
throw new Error('No response stream available');
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Read the stream
|
| 174 |
+
while (true) {
|
| 175 |
+
const { done, value } = await reader.read();
|
| 176 |
+
|
| 177 |
+
if (done) {
|
| 178 |
+
break;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// Convert the chunk to text and parse the JSON
|
| 182 |
+
const chunk = new TextDecoder().decode(value);
|
| 183 |
+
const lines = chunk.split('\n').filter(Boolean);
|
| 184 |
+
|
| 185 |
+
for (const line of lines) {
|
| 186 |
+
try {
|
| 187 |
+
const progress = JSON.parse(line) as UpdateProgress;
|
| 188 |
+
setUpdateProgress(progress);
|
| 189 |
+
|
| 190 |
+
if (progress.error) {
|
| 191 |
+
setError(progress.error);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// If we're done, update the UI accordingly
|
| 195 |
+
if (progress.stage === 'complete') {
|
| 196 |
+
setIsChecking(false);
|
| 197 |
+
|
| 198 |
+
if (!progress.error) {
|
| 199 |
+
// Update check completed
|
| 200 |
+
toast.success('Update check completed');
|
| 201 |
+
|
| 202 |
+
// Show update dialog only if there are changes and auto-update is disabled
|
| 203 |
+
if (progress.details?.changedFiles?.length && progress.details.updateReady) {
|
| 204 |
+
setShowUpdateDialog(true);
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
} catch (e) {
|
| 209 |
+
console.error('Error parsing progress update:', e);
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
} catch (error) {
|
| 214 |
+
setError(error instanceof Error ? error.message : 'Unknown error occurred');
|
| 215 |
+
logStore.logWarning('Update Check Failed', {
|
| 216 |
+
type: 'update',
|
| 217 |
+
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
| 218 |
+
});
|
| 219 |
+
} finally {
|
| 220 |
+
setIsChecking(false);
|
| 221 |
+
}
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
const handleUpdate = async () => {
|
| 225 |
+
setShowUpdateDialog(false);
|
| 226 |
+
|
| 227 |
+
try {
|
| 228 |
+
const branchToCheck = isLatestBranch ? 'main' : 'stable';
|
| 229 |
+
|
| 230 |
+
// Start the update with autoUpdate set to true to force the update
|
| 231 |
+
const response = await fetch('/api/update', {
|
| 232 |
+
method: 'POST',
|
| 233 |
+
headers: {
|
| 234 |
+
'Content-Type': 'application/json',
|
| 235 |
+
},
|
| 236 |
+
body: JSON.stringify({
|
| 237 |
+
branch: branchToCheck,
|
| 238 |
+
autoUpdate: true,
|
| 239 |
+
}),
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
if (!response.ok) {
|
| 243 |
+
throw new Error(`Update failed: ${response.statusText}`);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// Handle the update progress stream
|
| 247 |
+
const reader = response.body?.getReader();
|
| 248 |
+
|
| 249 |
+
if (!reader) {
|
| 250 |
+
throw new Error('No response stream available');
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
while (true) {
|
| 254 |
+
const { done, value } = await reader.read();
|
| 255 |
+
|
| 256 |
+
if (done) {
|
| 257 |
+
break;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
const chunk = new TextDecoder().decode(value);
|
| 261 |
+
const lines = chunk.split('\n').filter(Boolean);
|
| 262 |
+
|
| 263 |
+
for (const line of lines) {
|
| 264 |
+
try {
|
| 265 |
+
const progress = JSON.parse(line) as UpdateProgress;
|
| 266 |
+
setUpdateProgress(progress);
|
| 267 |
+
|
| 268 |
+
if (progress.error) {
|
| 269 |
+
setError(progress.error);
|
| 270 |
+
toast.error('Update failed');
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
if (progress.stage === 'complete' && !progress.error) {
|
| 274 |
+
toast.success('Update completed successfully');
|
| 275 |
+
}
|
| 276 |
+
} catch (e) {
|
| 277 |
+
console.error('Error parsing update progress:', e);
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
} catch (error) {
|
| 282 |
+
setError(error instanceof Error ? error.message : 'Unknown error occurred');
|
| 283 |
+
toast.error('Update failed');
|
| 284 |
+
}
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
return (
|
| 288 |
+
<div className="flex flex-col gap-6">
|
| 289 |
+
<motion.div
|
| 290 |
+
className="flex items-center gap-3"
|
| 291 |
+
initial={{ opacity: 0, y: -20 }}
|
| 292 |
+
animate={{ opacity: 1, y: 0 }}
|
| 293 |
+
transition={{ duration: 0.3 }}
|
| 294 |
+
>
|
| 295 |
+
<div className="i-ph:arrow-circle-up text-xl text-purple-500" />
|
| 296 |
+
<div>
|
| 297 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Updates</h3>
|
| 298 |
+
<p className="text-sm text-bolt-elements-textSecondary">Check for and manage application updates</p>
|
| 299 |
+
</div>
|
| 300 |
+
</motion.div>
|
| 301 |
+
|
| 302 |
+
{/* Update Settings Card */}
|
| 303 |
+
<motion.div
|
| 304 |
+
className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 305 |
+
initial={{ opacity: 0, y: 20 }}
|
| 306 |
+
animate={{ opacity: 1, y: 0 }}
|
| 307 |
+
transition={{ duration: 0.3, delay: 0.1 }}
|
| 308 |
+
>
|
| 309 |
+
<div className="flex items-center gap-3 mb-6">
|
| 310 |
+
<div className="i-ph:gear text-purple-500 w-5 h-5" />
|
| 311 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Update Settings</h3>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
<div className="space-y-4">
|
| 315 |
+
<div className="flex items-center justify-between">
|
| 316 |
+
<div>
|
| 317 |
+
<span className="text-sm text-bolt-elements-textPrimary">Automatic Updates</span>
|
| 318 |
+
<p className="text-xs text-bolt-elements-textSecondary">
|
| 319 |
+
Automatically check and apply updates when available
|
| 320 |
+
</p>
|
| 321 |
+
</div>
|
| 322 |
+
<button
|
| 323 |
+
onClick={() => setUpdateSettings((prev) => ({ ...prev, autoUpdate: !prev.autoUpdate }))}
|
| 324 |
+
className={classNames(
|
| 325 |
+
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
| 326 |
+
updateSettings.autoUpdate ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
|
| 327 |
+
)}
|
| 328 |
+
>
|
| 329 |
+
<span
|
| 330 |
+
className={classNames(
|
| 331 |
+
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
| 332 |
+
updateSettings.autoUpdate ? 'translate-x-6' : 'translate-x-1',
|
| 333 |
+
)}
|
| 334 |
+
/>
|
| 335 |
+
</button>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<div className="flex items-center justify-between">
|
| 339 |
+
<div>
|
| 340 |
+
<span className="text-sm text-bolt-elements-textPrimary">In-App Notifications</span>
|
| 341 |
+
<p className="text-xs text-bolt-elements-textSecondary">Show notifications when updates are available</p>
|
| 342 |
+
</div>
|
| 343 |
+
<button
|
| 344 |
+
onClick={() => setUpdateSettings((prev) => ({ ...prev, notifyInApp: !prev.notifyInApp }))}
|
| 345 |
+
className={classNames(
|
| 346 |
+
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
| 347 |
+
updateSettings.notifyInApp ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
|
| 348 |
+
)}
|
| 349 |
+
>
|
| 350 |
+
<span
|
| 351 |
+
className={classNames(
|
| 352 |
+
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
| 353 |
+
updateSettings.notifyInApp ? 'translate-x-6' : 'translate-x-1',
|
| 354 |
+
)}
|
| 355 |
+
/>
|
| 356 |
+
</button>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div className="flex items-center justify-between">
|
| 360 |
+
<div>
|
| 361 |
+
<span className="text-sm text-bolt-elements-textPrimary">Check Interval</span>
|
| 362 |
+
<p className="text-xs text-bolt-elements-textSecondary">How often to check for updates</p>
|
| 363 |
+
</div>
|
| 364 |
+
<select
|
| 365 |
+
value={updateSettings.checkInterval}
|
| 366 |
+
onChange={(e) => setUpdateSettings((prev) => ({ ...prev, checkInterval: Number(e.target.value) }))}
|
| 367 |
+
className={classNames(
|
| 368 |
+
'px-3 py-2 rounded-lg text-sm',
|
| 369 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 370 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
| 371 |
+
'text-bolt-elements-textPrimary',
|
| 372 |
+
'hover:bg-[#E5E5E5] dark:hover:bg-[#2A2A2A]',
|
| 373 |
+
'transition-colors duration-200',
|
| 374 |
+
)}
|
| 375 |
+
>
|
| 376 |
+
<option value="6">6 hours</option>
|
| 377 |
+
<option value="12">12 hours</option>
|
| 378 |
+
<option value="24">24 hours</option>
|
| 379 |
+
<option value="48">48 hours</option>
|
| 380 |
+
</select>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</motion.div>
|
| 384 |
+
|
| 385 |
+
{/* Update Status Card */}
|
| 386 |
+
<motion.div
|
| 387 |
+
className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
| 388 |
+
initial={{ opacity: 0, y: 20 }}
|
| 389 |
+
animate={{ opacity: 1, y: 0 }}
|
| 390 |
+
transition={{ duration: 0.3, delay: 0.2 }}
|
| 391 |
+
>
|
| 392 |
+
<div className="flex items-center justify-between mb-6">
|
| 393 |
+
<div className="flex items-center gap-3">
|
| 394 |
+
<div className="i-ph:arrows-clockwise text-purple-500 w-5 h-5" />
|
| 395 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Update Status</h3>
|
| 396 |
+
</div>
|
| 397 |
+
<div className="flex items-center gap-2">
|
| 398 |
+
{updateProgress?.details?.updateReady && !updateSettings.autoUpdate && (
|
| 399 |
+
<button
|
| 400 |
+
onClick={handleUpdate}
|
| 401 |
+
className={classNames(
|
| 402 |
+
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
|
| 403 |
+
'bg-purple-500 text-white',
|
| 404 |
+
'hover:bg-purple-600',
|
| 405 |
+
'transition-colors duration-200',
|
| 406 |
+
)}
|
| 407 |
+
>
|
| 408 |
+
<div className="i-ph:arrow-circle-up w-4 h-4" />
|
| 409 |
+
Update Now
|
| 410 |
+
</button>
|
| 411 |
+
)}
|
| 412 |
+
<button
|
| 413 |
+
onClick={() => {
|
| 414 |
+
setError(null);
|
| 415 |
+
checkForUpdates();
|
| 416 |
+
}}
|
| 417 |
+
className={classNames(
|
| 418 |
+
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
|
| 419 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 420 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
| 421 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
| 422 |
+
'text-bolt-elements-textPrimary',
|
| 423 |
+
'transition-colors duration-200',
|
| 424 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
| 425 |
+
)}
|
| 426 |
+
disabled={isChecking}
|
| 427 |
+
>
|
| 428 |
+
{isChecking ? (
|
| 429 |
+
<div className="flex items-center gap-2">
|
| 430 |
+
<motion.div
|
| 431 |
+
animate={{ rotate: 360 }}
|
| 432 |
+
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
| 433 |
+
className="i-ph:arrows-clockwise w-4 h-4"
|
| 434 |
+
/>
|
| 435 |
+
Checking...
|
| 436 |
+
</div>
|
| 437 |
+
) : (
|
| 438 |
+
<>
|
| 439 |
+
<div className="i-ph:arrows-clockwise w-4 h-4" />
|
| 440 |
+
Check for Updates
|
| 441 |
+
</>
|
| 442 |
+
)}
|
| 443 |
+
</button>
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
|
| 447 |
+
{/* Show progress information */}
|
| 448 |
+
{updateProgress && <UpdateProgressDisplay progress={updateProgress} />}
|
| 449 |
+
|
| 450 |
+
{error && <div className="mt-4 p-4 bg-red-100 text-red-700 rounded">{error}</div>}
|
| 451 |
+
|
| 452 |
+
{/* Show update source information */}
|
| 453 |
+
{updateProgress?.details?.currentCommit && updateProgress?.details?.remoteCommit && (
|
| 454 |
+
<div className="mt-4 text-sm text-bolt-elements-textSecondary">
|
| 455 |
+
<div className="flex items-center justify-between">
|
| 456 |
+
<div>
|
| 457 |
+
<p>
|
| 458 |
+
Updates are fetched from: <span className="font-mono">stackblitz-labs/bolt.diy</span> (
|
| 459 |
+
{isLatestBranch ? 'main' : 'stable'} branch)
|
| 460 |
+
</p>
|
| 461 |
+
<p className="mt-1">
|
| 462 |
+
Current version: <span className="font-mono">{updateProgress.details.currentCommit}</span>
|
| 463 |
+
<span className="mx-2">→</span>
|
| 464 |
+
Latest version: <span className="font-mono">{updateProgress.details.remoteCommit}</span>
|
| 465 |
+
</p>
|
| 466 |
+
</div>
|
| 467 |
+
{updateProgress?.details?.compareUrl && (
|
| 468 |
+
<a
|
| 469 |
+
href={updateProgress.details.compareUrl}
|
| 470 |
+
target="_blank"
|
| 471 |
+
rel="noopener noreferrer"
|
| 472 |
+
className={classNames(
|
| 473 |
+
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
|
| 474 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 475 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
| 476 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
| 477 |
+
'text-bolt-elements-textPrimary',
|
| 478 |
+
'transition-colors duration-200',
|
| 479 |
+
'w-fit',
|
| 480 |
+
)}
|
| 481 |
+
>
|
| 482 |
+
<div className="i-ph:github-logo w-4 h-4" />
|
| 483 |
+
View Changes on GitHub
|
| 484 |
+
</a>
|
| 485 |
+
)}
|
| 486 |
+
</div>
|
| 487 |
+
{updateProgress?.details?.additions !== undefined && updateProgress?.details?.deletions !== undefined && (
|
| 488 |
+
<div className="mt-2 flex items-center gap-2">
|
| 489 |
+
<div className="i-ph:git-diff text-purple-500 w-4 h-4" />
|
| 490 |
+
Changes: <span className="text-green-600">+{updateProgress.details.additions}</span>{' '}
|
| 491 |
+
<span className="text-red-600">-{updateProgress.details.deletions}</span>
|
| 492 |
+
</div>
|
| 493 |
+
)}
|
| 494 |
+
</div>
|
| 495 |
+
)}
|
| 496 |
+
|
| 497 |
+
{/* Add this before the changed files section */}
|
| 498 |
+
{updateProgress?.details?.changelog && (
|
| 499 |
+
<div className="mb-6">
|
| 500 |
+
<div className="flex items-center gap-2 mb-2">
|
| 501 |
+
<div className="i-ph:scroll text-purple-500 w-5 h-5" />
|
| 502 |
+
<p className="font-medium">Changelog</p>
|
| 503 |
+
</div>
|
| 504 |
+
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-4 overflow-auto max-h-[300px]">
|
| 505 |
+
<div className="prose dark:prose-invert prose-sm max-w-none">
|
| 506 |
+
<Markdown>{updateProgress.details.changelog}</Markdown>
|
| 507 |
+
</div>
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
)}
|
| 511 |
+
|
| 512 |
+
{/* Add this in the update status card, after the commit info */}
|
| 513 |
+
{updateProgress?.details?.compareUrl && (
|
| 514 |
+
<div className="mt-4">
|
| 515 |
+
<a
|
| 516 |
+
href={updateProgress.details.compareUrl}
|
| 517 |
+
target="_blank"
|
| 518 |
+
rel="noopener noreferrer"
|
| 519 |
+
className={classNames(
|
| 520 |
+
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
|
| 521 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 522 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
| 523 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
| 524 |
+
'text-bolt-elements-textPrimary',
|
| 525 |
+
'transition-colors duration-200',
|
| 526 |
+
'w-fit',
|
| 527 |
+
)}
|
| 528 |
+
>
|
| 529 |
+
<div className="i-ph:github-logo w-4 h-4" />
|
| 530 |
+
View Changes on GitHub
|
| 531 |
+
</a>
|
| 532 |
+
</div>
|
| 533 |
+
)}
|
| 534 |
+
|
| 535 |
+
{updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
|
| 536 |
+
<div className="mb-6">
|
| 537 |
+
<p className="font-medium mb-2">Changes in this Update:</p>
|
| 538 |
+
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-4 overflow-auto max-h-[400px]">
|
| 539 |
+
<div className="prose dark:prose-invert prose-sm max-w-none">
|
| 540 |
+
{updateProgress.details.commitMessages.map((section, index) => (
|
| 541 |
+
<Markdown key={index}>{section}</Markdown>
|
| 542 |
+
))}
|
| 543 |
+
</div>
|
| 544 |
+
</div>
|
| 545 |
+
</div>
|
| 546 |
+
)}
|
| 547 |
+
</motion.div>
|
| 548 |
+
|
| 549 |
+
{/* Update dialog */}
|
| 550 |
+
<DialogRoot open={showUpdateDialog} onOpenChange={setShowUpdateDialog}>
|
| 551 |
+
<Dialog>
|
| 552 |
+
<DialogTitle>Update Available</DialogTitle>
|
| 553 |
+
<DialogDescription>
|
| 554 |
+
<div className="mt-4">
|
| 555 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
| 556 |
+
A new version is available from <span className="font-mono">stackblitz-labs/bolt.diy</span> (
|
| 557 |
+
{isLatestBranch ? 'main' : 'stable'} branch)
|
| 558 |
+
</p>
|
| 559 |
+
|
| 560 |
+
{updateProgress?.details?.compareUrl && (
|
| 561 |
+
<div className="mb-6">
|
| 562 |
+
<a
|
| 563 |
+
href={updateProgress.details.compareUrl}
|
| 564 |
+
target="_blank"
|
| 565 |
+
rel="noopener noreferrer"
|
| 566 |
+
className={classNames(
|
| 567 |
+
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
|
| 568 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
| 569 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
| 570 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
| 571 |
+
'text-bolt-elements-textPrimary',
|
| 572 |
+
'transition-colors duration-200',
|
| 573 |
+
'w-fit',
|
| 574 |
+
)}
|
| 575 |
+
>
|
| 576 |
+
<div className="i-ph:github-logo w-4 h-4" />
|
| 577 |
+
View Changes on GitHub
|
| 578 |
+
</a>
|
| 579 |
+
</div>
|
| 580 |
+
)}
|
| 581 |
+
|
| 582 |
+
{updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
|
| 583 |
+
<div className="mb-6">
|
| 584 |
+
<p className="font-medium mb-2">Commit Messages:</p>
|
| 585 |
+
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-3 space-y-2">
|
| 586 |
+
{updateProgress.details.commitMessages.map((msg, index) => (
|
| 587 |
+
<div key={index} className="text-sm text-bolt-elements-textSecondary flex items-start gap-2">
|
| 588 |
+
<div className="i-ph:git-commit text-purple-500 w-4 h-4 mt-0.5 flex-shrink-0" />
|
| 589 |
+
<span>{msg}</span>
|
| 590 |
+
</div>
|
| 591 |
+
))}
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
)}
|
| 595 |
+
|
| 596 |
+
{updateProgress?.details?.totalSize && (
|
| 597 |
+
<div className="flex items-center gap-4 text-sm text-bolt-elements-textSecondary">
|
| 598 |
+
<div className="flex items-center gap-2">
|
| 599 |
+
<div className="i-ph:file text-purple-500 w-4 h-4" />
|
| 600 |
+
Total size: {updateProgress.details.totalSize}
|
| 601 |
+
</div>
|
| 602 |
+
{updateProgress?.details?.additions !== undefined &&
|
| 603 |
+
updateProgress?.details?.deletions !== undefined && (
|
| 604 |
+
<div className="flex items-center gap-2">
|
| 605 |
+
<div className="i-ph:git-diff text-purple-500 w-4 h-4" />
|
| 606 |
+
Changes: <span className="text-green-600">+{updateProgress.details.additions}</span>{' '}
|
| 607 |
+
<span className="text-red-600">-{updateProgress.details.deletions}</span>
|
| 608 |
+
</div>
|
| 609 |
+
)}
|
| 610 |
+
</div>
|
| 611 |
+
)}
|
| 612 |
+
</div>
|
| 613 |
+
</DialogDescription>
|
| 614 |
+
<div className="flex justify-end gap-2 mt-6">
|
| 615 |
+
<DialogButton type="secondary" onClick={() => setShowUpdateDialog(false)}>
|
| 616 |
+
Cancel
|
| 617 |
+
</DialogButton>
|
| 618 |
+
<DialogButton type="primary" onClick={handleUpdate}>
|
| 619 |
+
Update Now
|
| 620 |
+
</DialogButton>
|
| 621 |
+
</div>
|
| 622 |
+
</Dialog>
|
| 623 |
+
</DialogRoot>
|
| 624 |
+
</div>
|
| 625 |
+
);
|
| 626 |
+
};
|
| 627 |
+
|
| 628 |
+
export default UpdateTab;
|
app/components/@settings/utils/animations.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Variants } from 'framer-motion';
|
| 2 |
+
|
| 3 |
+
export const fadeIn: Variants = {
|
| 4 |
+
initial: { opacity: 0 },
|
| 5 |
+
animate: { opacity: 1 },
|
| 6 |
+
exit: { opacity: 0 },
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const slideIn: Variants = {
|
| 10 |
+
initial: { opacity: 0, y: 20 },
|
| 11 |
+
animate: { opacity: 1, y: 0 },
|
| 12 |
+
exit: { opacity: 0, y: -20 },
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export const scaleIn: Variants = {
|
| 16 |
+
initial: { opacity: 0, scale: 0.8 },
|
| 17 |
+
animate: { opacity: 1, scale: 1 },
|
| 18 |
+
exit: { opacity: 0, scale: 0.8 },
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export const tabAnimation: Variants = {
|
| 22 |
+
initial: { opacity: 0, scale: 0.8, y: 20 },
|
| 23 |
+
animate: { opacity: 1, scale: 1, y: 0 },
|
| 24 |
+
exit: { opacity: 0, scale: 0.8, y: -20 },
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export const overlayAnimation: Variants = {
|
| 28 |
+
initial: { opacity: 0 },
|
| 29 |
+
animate: { opacity: 1 },
|
| 30 |
+
exit: { opacity: 0 },
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export const modalAnimation: Variants = {
|
| 34 |
+
initial: { opacity: 0, scale: 0.95, y: 20 },
|
| 35 |
+
animate: { opacity: 1, scale: 1, y: 0 },
|
| 36 |
+
exit: { opacity: 0, scale: 0.95, y: 20 },
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
export const transition = {
|
| 40 |
+
duration: 0.2,
|
| 41 |
+
};
|
app/components/@settings/utils/tab-helpers.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types';
|
| 2 |
+
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
|
| 3 |
+
|
| 4 |
+
export const getVisibleTabs = (
|
| 5 |
+
tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] },
|
| 6 |
+
isDeveloperMode: boolean,
|
| 7 |
+
notificationsEnabled: boolean,
|
| 8 |
+
): TabVisibilityConfig[] => {
|
| 9 |
+
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
| 10 |
+
console.warn('Invalid tab configuration, using defaults');
|
| 11 |
+
return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// In developer mode, show ALL tabs without restrictions
|
| 15 |
+
if (isDeveloperMode) {
|
| 16 |
+
// Combine all unique tabs from both user and developer configurations
|
| 17 |
+
const allTabs = new Set([
|
| 18 |
+
...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
|
| 19 |
+
...tabConfiguration.userTabs.map((tab) => tab.id),
|
| 20 |
+
...(tabConfiguration.developerTabs || []).map((tab) => tab.id),
|
| 21 |
+
'task-manager' as TabType, // Always include task-manager in developer mode
|
| 22 |
+
]);
|
| 23 |
+
|
| 24 |
+
// Create a complete tab list with all tabs visible
|
| 25 |
+
const devTabs = Array.from(allTabs).map((tabId) => {
|
| 26 |
+
// Try to find existing configuration for this tab
|
| 27 |
+
const existingTab =
|
| 28 |
+
tabConfiguration.developerTabs?.find((t) => t.id === tabId) ||
|
| 29 |
+
tabConfiguration.userTabs?.find((t) => t.id === tabId) ||
|
| 30 |
+
DEFAULT_TAB_CONFIG.find((t) => t.id === tabId);
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
id: tabId as TabType,
|
| 34 |
+
visible: true,
|
| 35 |
+
window: 'developer' as const,
|
| 36 |
+
order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
|
| 37 |
+
} as TabVisibilityConfig;
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
return devTabs.sort((a, b) => a.order - b.order);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// In user mode, only show visible user tabs
|
| 44 |
+
return tabConfiguration.userTabs
|
| 45 |
+
.filter((tab) => {
|
| 46 |
+
if (!tab || typeof tab.id !== 'string') {
|
| 47 |
+
console.warn('Invalid tab entry:', tab);
|
| 48 |
+
return false;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Hide notifications tab if notifications are disabled
|
| 52 |
+
if (tab.id === 'notifications' && !notificationsEnabled) {
|
| 53 |
+
return false;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Always show task-manager in user mode if it's configured as visible
|
| 57 |
+
if (tab.id === 'task-manager') {
|
| 58 |
+
return tab.visible;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Only show tabs that are explicitly visible and assigned to the user window
|
| 62 |
+
return tab.visible && tab.window === 'user';
|
| 63 |
+
})
|
| 64 |
+
.sort((a, b) => a.order - b.order);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
export const reorderTabs = (
|
| 68 |
+
tabs: TabVisibilityConfig[],
|
| 69 |
+
startIndex: number,
|
| 70 |
+
endIndex: number,
|
| 71 |
+
): TabVisibilityConfig[] => {
|
| 72 |
+
const result = Array.from(tabs);
|
| 73 |
+
const [removed] = result.splice(startIndex, 1);
|
| 74 |
+
result.splice(endIndex, 0, removed);
|
| 75 |
+
|
| 76 |
+
// Update order property
|
| 77 |
+
return result.map((tab, index) => ({
|
| 78 |
+
...tab,
|
| 79 |
+
order: index,
|
| 80 |
+
}));
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
|
| 84 |
+
return DEFAULT_TAB_CONFIG.map((tab) => ({
|
| 85 |
+
...tab,
|
| 86 |
+
visible: isDeveloperMode ? true : tab.window === 'user',
|
| 87 |
+
window: isDeveloperMode ? 'developer' : tab.window,
|
| 88 |
+
})) as TabVisibilityConfig[];
|
| 89 |
+
};
|
app/components/chat/APIKeyManager.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { IconButton } from '~/components/ui/IconButton';
|
| 3 |
+
import { Switch } from '~/components/ui/Switch';
|
| 4 |
+
import type { ProviderInfo } from '~/types/model';
|
| 5 |
+
import Cookies from 'js-cookie';
|
| 6 |
+
interface APIKeyManagerProps {
|
| 7 |
+
provider: ProviderInfo;
|
| 8 |
+
apiKey: string;
|
| 9 |
+
setApiKey: (key: string) => void;
|
| 10 |
+
getApiKeyLink?: string;
|
| 11 |
+
labelForGetApiKey?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// cache which stores whether the provider's API key is set via environment variable
|
| 15 |
+
const providerEnvKeyStatusCache: Record<string, boolean> = {};
|
| 16 |
+
|
| 17 |
+
const apiKeyMemoizeCache: { [k: string]: Record<string, string> } = {};
|
| 18 |
+
|
| 19 |
+
export function getApiKeysFromCookies() {
|
| 20 |
+
const storedApiKeys = Cookies.get('apiKeys');
|
| 21 |
+
let parsedKeys: Record<string, string> = {};
|
| 22 |
+
|
| 23 |
+
if (storedApiKeys) {
|
| 24 |
+
parsedKeys = apiKeyMemoizeCache[storedApiKeys];
|
| 25 |
+
|
| 26 |
+
if (!parsedKeys) {
|
| 27 |
+
parsedKeys = apiKeyMemoizeCache[storedApiKeys] = JSON.parse(storedApiKeys);
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return parsedKeys;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
| 35 |
+
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
|
| 36 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 37 |
+
const [tempKey, setTempKey] = useState(apiKey);
|
| 38 |
+
const [isPromptCachingEnabled, setIsPromptCachingEnabled] = useState(() => {
|
| 39 |
+
// Read initial state from localStorage, defaulting to true
|
| 40 |
+
const savedState = localStorage.getItem('PROMPT_CACHING_ENABLED');
|
| 41 |
+
return savedState !== null ? JSON.parse(savedState) : true;
|
| 42 |
+
});
|
| 43 |
+
const [isEnvKeySet, setIsEnvKeySet] = useState(false);
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
// Update localStorage whenever the prompt caching state changes
|
| 47 |
+
localStorage.setItem('PROMPT_CACHING_ENABLED', JSON.stringify(isPromptCachingEnabled));
|
| 48 |
+
}, [isPromptCachingEnabled]);
|
| 49 |
+
|
| 50 |
+
// Reset states and load saved key when provider changes
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
// Load saved API key from cookies for this provider
|
| 53 |
+
const savedKeys = getApiKeysFromCookies();
|
| 54 |
+
const savedKey = savedKeys[provider.name] || '';
|
| 55 |
+
|
| 56 |
+
setTempKey(savedKey);
|
| 57 |
+
setApiKey(savedKey);
|
| 58 |
+
setIsEditing(false);
|
| 59 |
+
}, [provider.name]);
|
| 60 |
+
|
| 61 |
+
const checkEnvApiKey = useCallback(async () => {
|
| 62 |
+
// Check cache first
|
| 63 |
+
if (providerEnvKeyStatusCache[provider.name] !== undefined) {
|
| 64 |
+
setIsEnvKeySet(providerEnvKeyStatusCache[provider.name]);
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const response = await fetch(`/api/check-env-key?provider=${encodeURIComponent(provider.name)}`);
|
| 70 |
+
const data = await response.json();
|
| 71 |
+
const isSet = (data as { isSet: boolean }).isSet;
|
| 72 |
+
|
| 73 |
+
// Cache the result
|
| 74 |
+
providerEnvKeyStatusCache[provider.name] = isSet;
|
| 75 |
+
setIsEnvKeySet(isSet);
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Failed to check environment API key:', error);
|
| 78 |
+
setIsEnvKeySet(false);
|
| 79 |
+
}
|
| 80 |
+
}, [provider.name]);
|
| 81 |
+
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
checkEnvApiKey();
|
| 84 |
+
}, [checkEnvApiKey]);
|
| 85 |
+
|
| 86 |
+
const handleSave = () => {
|
| 87 |
+
// Save to parent state
|
| 88 |
+
setApiKey(tempKey);
|
| 89 |
+
|
| 90 |
+
// Save to cookies
|
| 91 |
+
const currentKeys = getApiKeysFromCookies();
|
| 92 |
+
const newKeys = { ...currentKeys, [provider.name]: tempKey };
|
| 93 |
+
Cookies.set('apiKeys', JSON.stringify(newKeys));
|
| 94 |
+
|
| 95 |
+
setIsEditing(false);
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="flex flex-col items-left justify-between py-3 px-1">
|
| 100 |
+
<div className="flex">
|
| 101 |
+
<div className="flex items-center gap-2 flex-1">
|
| 102 |
+
<div className="flex items-center gap-2">
|
| 103 |
+
<span className="text-sm font-medium text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
|
| 104 |
+
{!isEditing && (
|
| 105 |
+
<div className="flex items-center gap-2">
|
| 106 |
+
{apiKey ? (
|
| 107 |
+
<>
|
| 108 |
+
<div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
|
| 109 |
+
<span className="text-xs text-green-500">Set via UI</span>
|
| 110 |
+
</>
|
| 111 |
+
) : isEnvKeySet ? (
|
| 112 |
+
<>
|
| 113 |
+
<div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
|
| 114 |
+
<span className="text-xs text-green-500">Set via environment variable</span>
|
| 115 |
+
</>
|
| 116 |
+
) : (
|
| 117 |
+
<>
|
| 118 |
+
<div className="i-ph:x-circle-fill text-red-500 w-4 h-4" />
|
| 119 |
+
<span className="text-xs text-red-500">Not Set (Please set via UI or ENV_VAR)</span>
|
| 120 |
+
</>
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div className="flex items-center gap-2 shrink-0">
|
| 128 |
+
{isEditing ? (
|
| 129 |
+
<div className="flex items-center gap-2">
|
| 130 |
+
<input
|
| 131 |
+
type="password"
|
| 132 |
+
value={tempKey}
|
| 133 |
+
placeholder="Enter API Key"
|
| 134 |
+
onChange={(e) => setTempKey(e.target.value)}
|
| 135 |
+
className="w-[300px] px-3 py-1.5 text-sm rounded border border-bolt-elements-borderColor
|
| 136 |
+
bg-bolt-elements-prompt-background text-bolt-elements-textPrimary
|
| 137 |
+
focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus"
|
| 138 |
+
/>
|
| 139 |
+
<IconButton
|
| 140 |
+
onClick={handleSave}
|
| 141 |
+
title="Save API Key"
|
| 142 |
+
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
|
| 143 |
+
>
|
| 144 |
+
<div className="i-ph:check w-4 h-4" />
|
| 145 |
+
</IconButton>
|
| 146 |
+
<IconButton
|
| 147 |
+
onClick={() => setIsEditing(false)}
|
| 148 |
+
title="Cancel"
|
| 149 |
+
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
|
| 150 |
+
>
|
| 151 |
+
<div className="i-ph:x w-4 h-4" />
|
| 152 |
+
</IconButton>
|
| 153 |
+
</div>
|
| 154 |
+
) : (
|
| 155 |
+
<>
|
| 156 |
+
{
|
| 157 |
+
<IconButton
|
| 158 |
+
onClick={() => setIsEditing(true)}
|
| 159 |
+
title="Edit API Key"
|
| 160 |
+
className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
|
| 161 |
+
>
|
| 162 |
+
<div className="i-ph:pencil-simple w-4 h-4" />
|
| 163 |
+
</IconButton>
|
| 164 |
+
}
|
| 165 |
+
{provider?.getApiKeyLink && !apiKey && (
|
| 166 |
+
<IconButton
|
| 167 |
+
onClick={() => window.open(provider?.getApiKeyLink)}
|
| 168 |
+
title="Get API Key"
|
| 169 |
+
className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 flex items-center gap-2"
|
| 170 |
+
>
|
| 171 |
+
<span className="text-xs whitespace-nowrap">{provider?.labelForGetApiKey || 'Get API Key'}</span>
|
| 172 |
+
<div className={`${provider?.icon || 'i-ph:key'} w-4 h-4`} />
|
| 173 |
+
</IconButton>
|
| 174 |
+
)}
|
| 175 |
+
</>
|
| 176 |
+
)}
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{provider?.name === 'Anthropic' && (
|
| 181 |
+
<div className="border-t mt-4 pt-4 pb-2 -mt-4">
|
| 182 |
+
<div className="flex items-center space-x-2">
|
| 183 |
+
<Switch checked={isPromptCachingEnabled} onCheckedChange={setIsPromptCachingEnabled} />
|
| 184 |
+
<label htmlFor="prompt-caching" className="text-sm text-bolt-elements-textSecondary">
|
| 185 |
+
Enable Prompt Caching
|
| 186 |
+
</label>
|
| 187 |
+
</div>
|
| 188 |
+
<p className="text-xs text-bolt-elements-textTertiary mt-2">
|
| 189 |
+
When enabled, generates 10x cheaper responses if re-prompted within 5 mins (Recommended)
|
| 190 |
+
</p>
|
| 191 |
+
</div>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
};
|