Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apps are less cluttered now #79

Merged
merged 1 commit into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"react-toastify": "^10.0.5",
"recoil": "^0.7.7",
"recoil-persist": "^5.1.0",
"semver": "^7.6.3",
"totp-generator": "^1.0.0"
},
"devDependencies": {
Expand Down
52 changes: 39 additions & 13 deletions client/src/components/Integration/InstallApps.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil';
import { loadingState, loginState } from '../../atoms';
import { Helmet } from 'react-helmet';
import { GrAppsRounded } from "react-icons/gr";
import { FaSync, FaDownload } from "react-icons/fa";
import SingleHostedApp from './SingleHostedApp';
import useDynamicFilter from '../../hooks/useDynamicFilter';
import NoListing from '../Misc/NoListing';
Expand All @@ -12,14 +13,15 @@ import useCurrentRoute from '../../hooks/useCurrentRoute';
import NiceButton from '../NiceViews/NiceButton';
import makeToast from '../../utils/ToastUtils';
import { useNavigate } from 'react-router-dom';
import semver from 'semver';

const InstallApps = () => {
const navigate = useNavigate();
const fileInputRef = useRef(null);
const observerRef = useRef(null);
const setLoading = useSetRecoilState(loadingState);
const [appList, setAppList] = useState([]);
const [installedApps, setInstalledApps] = useState(new Set());
const [installedApps, setInstalledApps] = useState(new Map());
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
Expand All @@ -32,13 +34,27 @@ const InstallApps = () => {
const timestamp = new Date().getTime();
ApiService.get(`/api/v1/app/installed/all?timestamp=${timestamp}`, loginData?.token, navigate)
.then(data => {
setInstalledApps(new Set(data.message));
// Convert array to Map for easier lookup with version info
const appsMap = new Map(
data.message.map(app => [app.appId, app.version])
);
setInstalledApps(appsMap);
})
.catch((error) => {
if (!error.handled) makeToast("error", "Failed to fetch installed apps list.");
});
}, [loginData?.token, navigate]);

const checkForUpdates = (installedVersion, latestVersion) => {
if (!installedVersion || !latestVersion) return false;
try {
return semver.gt(latestVersion, installedVersion);
} catch (error) {
console.error('Version comparison error:', error);
return false;
}
};

const loadApps = useCallback((page = 1, append = false) => {
setIsLoadingMore(true);
if (page === 1) setLoading(true);
Expand All @@ -52,10 +68,7 @@ const InstallApps = () => {
setHasMore(false);
} else {
setAppList(prev => {
// If append is true, combine previous and new data, otherwise just use new data
const combinedApps = append ? [...prev, ...appsData] : appsData;

// Sort the combined array by appName
return combinedApps.sort((a, b) =>
a.appName.toLowerCase().localeCompare(b.appName.toLowerCase())
);
Expand All @@ -76,7 +89,6 @@ const InstallApps = () => {
loadApps(1, false);
}, [loadApps]);

// Infinite scroll setup
const lastElementRef = useCallback(node => {
if (isLoadingMore) return;
if (observerRef.current) observerRef.current.disconnect();
Expand Down Expand Up @@ -119,7 +131,7 @@ const InstallApps = () => {
ApiService.postWithFormData('/api/v1/app/fromzip', formData, loginData?.token, navigate)
.then(() => {
makeToast("success", "Integration from zip is installed.");
fetchInstalledApps(); // Refresh installed apps list after successful upload
fetchInstalledApps();
navigate("/manage/apps");
})
.catch((error) => {
Expand Down Expand Up @@ -165,22 +177,36 @@ const InstallApps = () => {
<div className="mt-8">
{appList?.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{appList.map((app, index) => (
index === appList.length - 1 ? (
{appList.map((app, index) => {
const installedVersion = installedApps.get(app.appId);
const hasUpdate = checkForUpdates(installedVersion, app.version);
const isInstalled = installedApps.has(app.appId);

const StatusIcon = isInstalled ?
(hasUpdate ? FaDownload : FaSync) :
null;

return index === appList.length - 1 ? (
<div key={`${app.appId}_${index}`} ref={lastElementRef}>
<SingleHostedApp
app={app}
isInstalled={installedApps.has(app.appId)}
isInstalled={isInstalled}
StatusIcon={StatusIcon}
hasUpdate={hasUpdate}
installedVersion={installedVersion}
/>
</div>
) : (
<SingleHostedApp
key={`${app.appId}_${index}`}
app={app}
isInstalled={installedApps.has(app.appId)}
isInstalled={isInstalled}
StatusIcon={StatusIcon}
hasUpdate={hasUpdate}
installedVersion={installedVersion}
/>
)
))}
);
})}
</div>
) : (
<NoListing
Expand Down
99 changes: 82 additions & 17 deletions client/src/components/Integration/SingleHostedApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ import { loadingState, loginState } from '../../atoms';
import ApiService from '../../utils/ApiService';
import makeToast from '../../utils/ToastUtils';

const SingleHostedApp = ({ app, isInstalled }) => {
const SingleHostedApp = ({
app,
isInstalled,
StatusIcon,
hasUpdate,
installedVersion
}) => {
const navigate = useNavigate();

const [isInstalling, setIsInstalling] = useState(false);
const setLoading = useSetRecoilState(loadingState);
const loginData = useRecoilValue(loginState);
Expand All @@ -24,7 +29,7 @@ const SingleHostedApp = ({ app, isInstalled }) => {
const doInstallIntegration = () => {
setIsInstalling(true);

ApiService.get(`/api/v1/app/${app.appId === "com." ? "com.youtube" : app.appId}/install`, loginData?.token, navigate)
ApiService.get(`/api/v1/app/${app.appId}/install`, loginData?.token, navigate)
.then(() => {
makeToast("success", "Integration installed.");
navigate("/manage/apps");
Expand All @@ -37,6 +42,22 @@ const SingleHostedApp = ({ app, isInstalled }) => {
setIsInstalling(false);
});
}
const doUpdateIntegration = () => {
setIsInstalling(true);

ApiService.get(`/api/v1/app/${app.appId}/update`, loginData?.token, navigate)
.then(() => {
makeToast("success", "Integration updated.");
navigate("/manage/apps");
})
.catch((error) => {
if (!error.handled) makeToast("error", error?.response?.data?.message || "Integration cannot be updated.");
})
.finally(() => {
setLoading(false);
setIsInstalling(false);
});
}

return (
<div role="button" className="relative cursor-pointer">
Expand All @@ -45,13 +66,27 @@ const SingleHostedApp = ({ app, isInstalled }) => {
className="relative border-2 border-internalCardBorder bg-internalCardBg text-internalCardText p-6 rounded-xl shadow-md"
style={{ minHeight: '380px' }}
>
<div
title="Homepage"
role="button"
onClick={handleHomepage}
className="absolute top-0 right-0 p-2 cursor-pointer opacity-50 m-2 transition-opacity hover:opacity-100 ml-8 text-internalCardIconColor hover:text-internalCardIconHoverColor"
>
<FaHome size={20} />
<div className="absolute top-0 right-0 flex items-center space-x-2 p-2 m-2">
{isInstalled && StatusIcon && (
<div
onClick={doUpdateIntegration}
className="cursor-pointer opacity-50 hover:opacity-100 transition-opacity"
title={hasUpdate ? `Update available (${app.version})` : 'Up to date'}
>
<StatusIcon
size={16}
className={hasUpdate ? 'text-yellow-500' : 'text-green-500'}
/>
</div>
)}
<div
title="Homepage"
role="button"
onClick={handleHomepage}
className="cursor-pointer opacity-50 transition-opacity hover:opacity-100 text-internalCardIconColor hover:text-internalCardIconHoverColor"
>
<FaHome size={20} />
</div>
</div>

<div className='flex items-center justify-center mb-4'>
Expand All @@ -74,7 +109,20 @@ const SingleHostedApp = ({ app, isInstalled }) => {
<div className='text-xs text-internalCardText/60'>
<div className='flex justify-between'>
<span className='text-internalCardText/60'>Version:</span>
<span>{app.version}</span>
<span>
{isInstalled ? (
<>
{installedVersion}
{hasUpdate && (
<span className="ml-1 text-yellow-500">
→ {app.version}
</span>
)}
</>
) : (
app.version
)}
</span>
</div>

<div className='flex justify-between'>
Expand All @@ -92,12 +140,25 @@ const SingleHostedApp = ({ app, isInstalled }) => {
</div>

<NiceButton
onClick={doInstallIntegration}
label={isInstalled ? "Installed" : isInstalling ? 'Installing...' : 'Install'}
disabled={isInstalled || isInstalling}
onClick={isInstalled && hasUpdate ? doUpdateIntegration : doInstallIntegration}
label={
isInstalled
? hasUpdate
? "Update Available"
: "Installed"
: isInstalling
? 'Installing...'
: 'Install'
}
disabled={isInstalled && !hasUpdate || isInstalling}
parentClassname="w-full"
className="mt-6 bg-buttonGeneric text-buttonText w-full" />

className={`mt-6 w-full ${isInstalled
? hasUpdate
? "bg-yellow-500 text-white"
: "bg-buttonGeneric text-buttonText"
: "bg-buttonGeneric text-buttonText"
}`}
/>
</div>
</div>
</motion.div>
Expand All @@ -106,7 +167,11 @@ const SingleHostedApp = ({ app, isInstalled }) => {
};

SingleHostedApp.propTypes = {
app: PropTypes.object.isRequired
app: PropTypes.object.isRequired,
isInstalled: PropTypes.bool,
StatusIcon: PropTypes.elementType,
hasUpdate: PropTypes.bool,
installedVersion: PropTypes.string
};

const MemoizedComponent = React.memo(SingleHostedApp);
Expand Down
5 changes: 2 additions & 3 deletions server/apps/com.github/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const axios = require('axios');
const moment = require('moment');

const extractRepoInfo = (url) => {
Expand Down Expand Up @@ -26,7 +25,7 @@ const connectionTest = async (testerInstance) => {

const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;

await axios.get(apiUrl, {
await testerInstance?.axios.get(apiUrl, {
auth: {
username,
password,
Expand Down Expand Up @@ -57,7 +56,7 @@ const initialize = async (application) => {

const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;

const response = await axios.get(apiUrl, {
const response = await application?.axios.get(apiUrl, {
auth: {
username,
password,
Expand Down
1 change: 0 additions & 1 deletion server/apps/com.github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.7.4",
"moment": "^2.30.1"
}
}
5 changes: 2 additions & 3 deletions server/apps/com.heimdall/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const axios = require('axios');

const connectionTest = async (testerInstance) => {
try {
Expand All @@ -11,7 +10,7 @@ const connectionTest = async (testerInstance) => {

//console.log(`${connectionUrl}/health`);

const response = await axios.get(`${connectionUrl}/health`);
const response = await testerInstance?.axios.get(`${connectionUrl}/health`);

if (response.status === 200) {
await testerInstance.connectionSuccess();
Expand All @@ -35,7 +34,7 @@ const initialize = async (application) => {

try {

const response = await axios.get(`${appUrl}/health`);
const response = await application?.axios.get(`${appUrl}/health`);

const data = response.data;

Expand Down
1 change: 0 additions & 1 deletion server/apps/com.heimdall/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,5 @@
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.7.4"
}
}
Binary file modified server/apps/com.heimdall/public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 4 additions & 5 deletions server/apps/com.portainer/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const axios = require('axios');

const connectionTest = async (testerInstance) => {
try {
Expand All @@ -12,7 +11,7 @@ const connectionTest = async (testerInstance) => {

const authUrl = `${connectionUrl}/api/auth`;

const response = await axios.post(authUrl, {
const response = await testerInstance?.axios.post(authUrl, {
username,
password
});
Expand Down Expand Up @@ -45,15 +44,15 @@ const initialize = async (application) => {

try {
// Step 1: Authenticate using the access token to get a JWT token
const authResponse = await axios.post(authUrl, {
const authResponse = await application?.axios.post(authUrl, {
username,
password
});

const jwtToken = authResponse.data.jwt;

// Step 2: Fetch the available endpoints
const endpointsResponse = await axios.get(endpointsUrl, {
const endpointsResponse = await application?.axios.get(endpointsUrl, {
headers: {
'Authorization': `Bearer ${jwtToken}`
}
Expand All @@ -65,7 +64,7 @@ const initialize = async (application) => {

// Step 3: Use the endpoint ID to fetch data from Portainer
const apiUrl = `${sanitizedListingUrl}/api/endpoints/${endpointId}/docker/info`;
const response = await axios.get(apiUrl, {
const response = await application?.axios.get(apiUrl, {
headers: {
'Authorization': `Bearer ${jwtToken}`
}
Expand Down
1 change: 0 additions & 1 deletion server/apps/com.portainer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,5 @@
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.7.4"
}
}
Loading