Package Descriptions
Social Metrics
This commit is contained in:
Muhammad Ibrahim
2026-01-04 14:44:57 +00:00
parent fcc38cb71f
commit c6d07121a6
17 changed files with 528 additions and 210 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43 -8
View File
@@ -354,14 +354,29 @@ router.post(
const _crypto = require("node:crypto");
// Create assets directory if it doesn't exist
// In Docker: use ASSETS_DIR environment variable (mounted volume)
// In development: save to public/assets (served by Vite)
// In production: save to dist/assets (served by built app)
const isDevelopment = process.env.NODE_ENV !== "production";
const assetsDir = isDevelopment
? path.join(__dirname, "../../../frontend/public/assets")
: path.join(__dirname, "../../../frontend/dist/assets");
// In production (non-Docker): save to public/assets (build copies to dist/)
const assetsDir = process.env.ASSETS_DIR
? path.resolve(process.env.ASSETS_DIR)
: path.resolve(__dirname, "../../../frontend/public/assets");
console.log(`📁 Assets directory: ${assetsDir}`);
await fs.mkdir(assetsDir, { recursive: true });
// Verify directory exists
const dirExists = await fs
.access(assetsDir)
.then(() => true)
.catch(() => false);
if (!dirExists) {
throw new Error(
`Failed to create or access assets directory: ${assetsDir}`,
);
}
// Determine file extension and path
let fileExtension;
let fileName_final;
@@ -386,7 +401,8 @@ router.post(
fileName_final = fileName || `logo_${logoType}${fileExtension}`;
}
const filePath = path.join(assetsDir, fileName_final);
const filePath = path.resolve(assetsDir, fileName_final);
console.log(`📄 Full file path: ${filePath}`);
// Handle base64 data URLs
let fileBuffer;
@@ -412,6 +428,17 @@ router.post(
// Write new logo file
await fs.writeFile(filePath, fileBuffer);
console.log(`✅ Logo file written to: ${filePath}`);
// Verify file was written
const fileExists = await fs
.access(filePath)
.then(() => true)
.catch(() => false);
if (!fileExists) {
throw new Error(`Failed to verify file was written: ${filePath}`);
}
// Update settings with new logo path
const settings = await getSettings();
@@ -426,7 +453,10 @@ router.post(
updateData.favicon = logoPath;
}
await updateSettings(settings.id, updateData);
const updatedSettings = await updateSettings(settings.id, updateData);
console.log(
`✅ Settings updated with new ${logoType} logo path: ${logoPath}`,
);
// Get file stats
const stats = await fs.stat(filePath);
@@ -437,10 +467,15 @@ router.post(
path: logoPath,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
timestamp: updatedSettings.updated_at.getTime(), // Include timestamp for cache busting
});
} catch (error) {
console.error("Upload logo error:", error);
res.status(500).json({ error: "Failed to upload logo" });
res.status(500).json({
error: "Failed to upload logo",
details: error.message || "Unknown error",
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
});
}
},
);
+1 -1
View File
@@ -386,7 +386,7 @@ app.use(
credentials: true,
// Additional CORS options for better cookie handling
optionsSuccessStatus: 200,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
+5
View File
@@ -68,10 +68,13 @@ services:
REDIS_PORT: 6379
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
REDIS_DB: 0
# Assets directory for custom branding (logos, favicons)
ASSETS_DIR: /app/assets
ports:
- "3001:3001"
volumes:
- ./compose_dev_data/agents:/app/agents
- ./compose_dev_data/assets:/app/assets
depends_on:
database:
condition: service_healthy
@@ -102,6 +105,8 @@ services:
BACKEND_PORT: 3001
ports:
- "3000:3000"
volumes:
- ./compose_dev_data/assets:/app/frontend/public/assets
depends_on:
backend:
condition: service_healthy
+6
View File
@@ -74,8 +74,11 @@ services:
REDIS_PORT: 6379
REDIS_PASSWORD: your-redis-password-here
REDIS_DB: 0
# Assets directory for custom branding (logos, favicons)
ASSETS_DIR: /app/assets
volumes:
- agent_files:/app/agents
- branding_assets:/app/assets
depends_on:
database:
condition: service_healthy
@@ -87,6 +90,8 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- branding_assets:/usr/share/nginx/html/assets
depends_on:
backend:
condition: service_healthy
@@ -95,3 +100,4 @@ volumes:
postgres_data:
redis_data:
agent_files:
branding_assets:
+10
View File
@@ -95,6 +95,16 @@ server {
}
# Custom branding assets (logos, favicons) - served from mounted volume
# This allows logos to persist across container restarts
location /assets/ {
alias /usr/share/nginx/html/assets/;
expires 1h;
add_header Cache-Control "public, must-revalidate";
# Allow CORS for assets if needed
add_header Access-Control-Allow-Origin *;
}
# Static assets caching (exclude Bull Board assets)
location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+144 -89
View File
@@ -25,13 +25,13 @@ import {
X,
Zap,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { FaLinkedin, FaYoutube } from "react-icons/fa";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { dashboardAPI, versionAPI } from "../utils/api";
import { dashboardAPI, settingsAPI, versionAPI } from "../utils/api";
import DiscordIcon from "./DiscordIcon";
import GlobalSearch from "./GlobalSearch";
import Logo from "./Logo";
@@ -96,6 +96,12 @@ const Layout = ({ children }) => {
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Fetch settings for favicon
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Fetch version info
const { data: versionInfo } = useQuery({
queryKey: ["versionInfo"],
@@ -403,24 +409,51 @@ const Layout = ({ children }) => {
};
}, [themeConfig]);
// Fetch social media stats from cache
const fetchSocialMediaStats = useCallback(async () => {
try {
const response = await fetch("/api/v1/social-media-stats");
if (response.ok) {
const data = await response.json();
setSocialMediaStats({
github_stars: data.github_stars,
discord_members: data.discord_members,
buymeacoffee_supporters: data.buymeacoffee_supporters,
youtube_subscribers: data.youtube_subscribers,
linkedin_followers: data.linkedin_followers,
});
// Fetch social media stats from cache - only once on mount
// Using useRef to track if we've already fetched to prevent re-fetching on settings updates
const hasFetchedSocialMedia = useRef(false);
useEffect(() => {
// Only fetch once on component mount, not when settings change
if (hasFetchedSocialMedia.current) return;
const fetchSocialMediaStats = async () => {
try {
const response = await fetch("/api/v1/social-media-stats");
if (response.ok) {
const data = await response.json();
// Only update stats that are not null - preserve existing values if fetch failed
setSocialMediaStats((prev) => ({
github_stars:
data.github_stars !== null
? data.github_stars
: prev.github_stars,
discord_members:
data.discord_members !== null
? data.discord_members
: prev.discord_members,
buymeacoffee_supporters:
data.buymeacoffee_supporters !== null
? data.buymeacoffee_supporters
: prev.buymeacoffee_supporters,
youtube_subscribers:
data.youtube_subscribers !== null
? data.youtube_subscribers
: prev.youtube_subscribers,
linkedin_followers:
data.linkedin_followers !== null
? data.linkedin_followers
: prev.linkedin_followers,
}));
}
} catch (error) {
console.error("Failed to fetch social media stats:", error);
}
} catch (error) {
console.error("Failed to fetch social media stats:", error);
}
}, []);
};
fetchSocialMediaStats();
hasFetchedSocialMedia.current = true;
}, []); // Empty dependency array - only run once on mount
// Short format for navigation area
const formatRelativeTimeShort = (date) => {
@@ -463,11 +496,6 @@ const Layout = ({ children }) => {
};
}, []);
// Fetch social media stats on component mount
useEffect(() => {
fetchSocialMediaStats();
}, [fetchSocialMediaStats]);
// Set CSS custom properties for glassmorphism and theme colors in dark mode
useEffect(() => {
const updateThemeStyles = () => {
@@ -815,9 +843,28 @@ const Layout = ({ children }) => {
{sidebarCollapsed ? (
<Link to="/" className="flex items-center">
<img
src="/assets/favicon.svg"
src={
settings?.favicon
? `${(() => {
const parts = settings.favicon.split("/");
const filename = parts.pop();
const directory = parts.join("/");
const encodedPath = directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
return `${encodedPath}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`;
})()}`
: "/assets/favicon.svg"
}
alt="PatchMon"
className="h-12 w-12 object-contain"
onError={(e) => {
e.target.src = "/assets/favicon.svg";
}}
/>
</Link>
) : (
@@ -904,6 +951,20 @@ const Layout = ({ children }) => {
{stats.cards.totalHosts}
</span>
)}
{/* {subItem.name === "Packages" &&
stats?.cards?.totalOutdatedPackages !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalOutdatedPackages}
</span>
)} */}
{/* {subItem.name === "Repos" &&
stats?.cards?.totalRepos !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalRepos}
</span>
)} */}
</span>
)}
{!sidebarCollapsed && (
@@ -962,6 +1023,20 @@ const Layout = ({ children }) => {
{stats.cards.totalHosts}
</span>
)}
{/* {subItem.name === "Packages" &&
stats?.cards?.totalOutdatedPackages !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalOutdatedPackages}
</span>
)} */}
{/* {subItem.name === "Repos" &&
stats?.cards?.totalRepos !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalRepos}
</span>
)} */}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
@@ -1048,70 +1123,50 @@ const Layout = ({ children }) => {
)}
{/* External Links Section - Roadmap, Documentation, Email, Website */}
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-4 mt-4">
<div
className={`flex ${sidebarCollapsed ? "flex-col items-center gap-2" : "flex-wrap gap-2 -mx-2 px-2"}`}
>
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
title={sidebarCollapsed ? "Roadmap" : ""}
>
<Route className="h-5 w-5" />
{!sidebarCollapsed && (
<span className="ml-2 text-sm font-medium truncate">
Roadmap
</span>
)}
</a>
{/* Documentation */}
<a
href="https://docs.patchmon.net"
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
title={sidebarCollapsed ? "Documentation" : ""}
>
<BookOpen className="h-5 w-5" />
{!sidebarCollapsed && (
<span className="ml-2 text-sm font-medium truncate">
Docs
</span>
)}
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
title={sidebarCollapsed ? "Email Support" : ""}
>
<Mail className="h-5 w-5" />
{!sidebarCollapsed && (
<span className="ml-2 text-sm font-medium truncate">
Email
</span>
)}
</a>
{/* Website */}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
title={sidebarCollapsed ? "Website" : ""}
>
<Globe className="h-5 w-5" />
{!sidebarCollapsed && (
<span className="ml-2 text-sm font-medium truncate">
Website
</span>
)}
</a>
{!sidebarCollapsed && (
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-4 mt-4">
<div className="flex items-center justify-center gap-2">
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
title="Roadmap"
>
<Route className="h-5 w-5" />
</a>
{/* Documentation */}
<a
href="https://docs.patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
title="Documentation"
>
<BookOpen className="h-5 w-5" />
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
title="Email Support"
>
<Mail className="h-5 w-5" />
</a>
{/* Website */}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
title="Website"
>
<Globe className="h-5 w-5" />
</a>
</div>
</div>
</div>
)}
</nav>
{/* Profile Section - Bottom of Sidebar */}
@@ -1455,7 +1510,7 @@ const Layout = ({ children }) => {
href="https://github.com/PatchMon/PatchMon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-transparent text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors shadow-sm group relative"
className="flex items-center justify-center gap-1.5 w-auto px-2.5 h-10 bg-gray-50 dark:bg-transparent text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors shadow-sm group relative"
style={{
backgroundColor: "var(--button-bg, rgb(249, 250, 251))",
backdropFilter: "var(--button-blur, none)",
+17 -1
View File
@@ -14,16 +14,32 @@ const Logo = ({
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to encode logo path for URLs (handles spaces and special characters)
const encodeLogoPath = (path) => {
if (!path) return path;
// Split path into directory and filename
const parts = path.split("/");
const filename = parts.pop();
const directory = parts.join("/");
// Encode only the filename part, keep directory structure
return directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
};
// Determine which logo to use based on theme
const logoSrc = isDark
? settings?.logo_dark || "/assets/logo_dark.png"
: settings?.logo_light || "/assets/logo_light.png";
// Encode the path to handle spaces and special characters
const encodedLogoSrc = encodeLogoPath(logoSrc);
// Add cache-busting parameter using updated_at timestamp
const cacheBuster = settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now();
const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
const logoSrcWithCache = `${encodedLogoSrc}?v=${cacheBuster}`;
return (
<img
+13 -1
View File
@@ -8,11 +8,23 @@ const LogoProvider = ({ children }) => {
// Use custom favicon or fallback to default
const faviconUrl = settings?.favicon || "/assets/favicon.svg";
// Encode the path to handle spaces and special characters
const encodeLogoPath = (path) => {
if (!path) return path;
const parts = path.split("/");
const filename = parts.pop();
const directory = parts.join("/");
return directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
};
const encodedFaviconUrl = encodeLogoPath(faviconUrl);
// Add cache-busting parameter using updated_at timestamp
const cacheBuster = settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now();
const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
const faviconUrlWithCache = `${encodedFaviconUrl}?v=${cacheBuster}`;
// Update favicon
const favicon = document.querySelector('link[rel="icon"]');
@@ -25,19 +25,45 @@ const BrandingTab = () => {
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to encode logo path for URLs (handles spaces and special characters)
const encodeLogoPath = (path) => {
if (!path) return path;
// Split path into directory and filename
const parts = path.split("/");
const filename = parts.pop();
const directory = parts.join("/");
// Encode only the filename part, keep directory structure
return directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
};
// Logo upload mutation
const uploadLogoMutation = useMutation({
mutationFn: ({ logoType, fileContent, fileName }) =>
fetch("/api/v1/settings/logos/upload", {
mutationFn: async ({ logoType, fileContent, fileName }) => {
const res = await fetch("/api/v1/settings/logos/upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ logoType, fileContent, fileName }),
}).then((res) => res.json()),
onSuccess: (_data, variables) => {
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || data.details || "Failed to upload logo");
}
return data;
},
onSuccess: async (data, variables) => {
// Invalidate and refetch settings to get updated timestamp
queryClient.invalidateQueries(["settings"]);
// Wait for refetch to complete before closing modal
try {
await queryClient.refetchQueries(["settings"]);
} catch (error) {
// Continue anyway - settings will update on next render
}
setLogoUploadState((prev) => ({
...prev,
[variables.logoType]: { uploading: false, error: null },
@@ -133,7 +159,12 @@ const BrandingTab = () => {
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
key={`dark-${settings?.logo_dark}-${settings?.updated_at}`}
src={`${encodeLogoPath(settings?.logo_dark || "/assets/logo_dark.png")}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`}
alt="Dark Logo"
className="max-h-16 max-w-full object-contain"
onError={(e) => {
@@ -194,7 +225,12 @@ const BrandingTab = () => {
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
key={`light-${settings?.logo_light}-${settings?.updated_at}`}
src={`${encodeLogoPath(settings?.logo_light || "/assets/logo_light.png")}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`}
alt="Light Logo"
className="max-h-16 max-w-full object-contain"
onError={(e) => {
@@ -255,7 +291,12 @@ const BrandingTab = () => {
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
key={`favicon-${settings?.favicon}-${settings?.updated_at}`}
src={`${encodeLogoPath(settings?.favicon || "/assets/favicon.svg")}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`}
alt="Favicon"
className="h-8 w-8 object-contain"
onError={(e) => {
+1 -1
View File
@@ -1604,7 +1604,7 @@ const Dashboard = () => {
<button
type="button"
onClick={() => setShowSettingsModal(true)}
className="hidden md:flex btn-outline items-center gap-2"
className="hidden md:flex btn-outline items-center gap-2 min-h-[44px] min-w-[44px] justify-center"
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />
+2 -2
View File
@@ -881,7 +881,7 @@ const HostDetail = () => {
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-3">
Network Interfaces
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{host.network_interfaces.map((iface) => (
<div
key={iface.name}
@@ -1812,7 +1812,7 @@ const HostDetail = () => {
<Wifi className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Network Interfaces
</h4>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{host.network_interfaces.map((iface) => (
<div
key={iface.name}
+122 -55
View File
@@ -1,3 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
@@ -12,7 +13,6 @@ import {
Star,
User,
} from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { FaLinkedin, FaYoutube } from "react-icons/fa";
@@ -20,9 +20,18 @@ import { useNavigate } from "react-router-dom";
import DiscordIcon from "../components/DiscordIcon";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { authAPI, isCorsError } from "../utils/api";
import { authAPI, isCorsError, settingsAPI } from "../utils/api";
const Login = () => {
// Helper function to format numbers in k format (e.g., 1704 -> 1.8k)
const formatNumber = (num) => {
if (num >= 1000) {
const rounded = Math.ceil((num / 1000) * 10) / 10; // Round up to 1 decimal place
return `${rounded.toFixed(1)}K`;
}
return num.toString();
};
const usernameId = useId();
const firstNameId = useId();
const lastNameId = useId();
@@ -64,6 +73,12 @@ const Login = () => {
const navigate = useNavigate();
// Fetch settings for favicon
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Generate clean radial gradient background with subtle triangular accents
useEffect(() => {
const generateBackground = () => {
@@ -204,13 +219,29 @@ const Login = () => {
const statsResponse = await fetch("/api/v1/social-media-stats");
if (statsResponse.ok) {
const statsData = await statsResponse.json();
setSocialMediaStats({
github_stars: statsData.github_stars,
discord_members: statsData.discord_members,
buymeacoffee_supporters: statsData.buymeacoffee_supporters,
youtube_subscribers: statsData.youtube_subscribers,
linkedin_followers: statsData.linkedin_followers,
});
// Only update stats that are not null - preserve existing values if fetch failed
setSocialMediaStats((prev) => ({
github_stars:
statsData.github_stars !== null
? statsData.github_stars
: prev.github_stars,
discord_members:
statsData.discord_members !== null
? statsData.discord_members
: prev.discord_members,
buymeacoffee_supporters:
statsData.buymeacoffee_supporters !== null
? statsData.buymeacoffee_supporters
: prev.buymeacoffee_supporters,
youtube_subscribers:
statsData.youtube_subscribers !== null
? statsData.youtube_subscribers
: prev.youtube_subscribers,
linkedin_followers:
statsData.linkedin_followers !== null
? statsData.linkedin_followers
: prev.linkedin_followers,
}));
}
// Use cache if less than 1 hour old
@@ -572,12 +603,60 @@ const Login = () => {
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current text-yellow-400" />
<span className="text-sm font-medium text-white">
{socialMediaStats.github_stars}
{formatNumber(socialMediaStats.github_stars)}
</span>
</div>
)}
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
{socialMediaStats.discord_members !== null && (
<span className="text-sm font-medium text-white">
{socialMediaStats.discord_members}
</span>
)}
</a>
{/* LinkedIn */}
<a
href="https://linkedin.com/company/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="LinkedIn Company Page"
>
<FaLinkedin className="h-5 w-5 text-[#0077B5]" />
{socialMediaStats.linkedin_followers !== null && (
<span className="text-sm font-medium text-white">
{socialMediaStats.linkedin_followers}
</span>
)}
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-[#FF0000]" />
{socialMediaStats.youtube_subscribers !== null && (
<span className="text-sm font-medium text-white">
{socialMediaStats.youtube_subscribers}
</span>
)}
</a>
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
@@ -589,7 +668,7 @@ const Login = () => {
<Route className="h-5 w-5 text-white" />
</a>
{/* Docs */}
{/* Documentation */}
<a
href="https://docs.patchmon.net"
target="_blank"
@@ -600,48 +679,6 @@ const Login = () => {
<BookOpen className="h-5 w-5 text-white" />
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Email Support"
>
<Mail className="h-5 w-5 text-white" />
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-[#FF0000]" />
</a>
{/* Reddit */}
<a
href="https://linkedin.com/company/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="LinkedIn Company Page"
>
<FaLinkedin className="h-5 w-5 text-[#0077B5]" />
</a>
{/* Website */}
<a
href="https://patchmon.net"
@@ -667,9 +704,28 @@ const Login = () => {
<div>
<div className="mx-auto h-16 w-16 flex items-center justify-center">
<img
src="/assets/favicon.svg"
src={
settings?.favicon
? `${(() => {
const parts = settings.favicon.split("/");
const filename = parts.pop();
const directory = parts.join("/");
const encodedPath = directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
return `${encodedPath}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`;
})()}`
: "/assets/favicon.svg"
}
alt="PatchMon Logo"
className="h-16 w-16"
onError={(e) => {
e.target.src = "/assets/favicon.svg";
}}
/>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
@@ -880,9 +936,20 @@ const Login = () => {
<div className="text-center">
<div className="mx-auto h-16 w-16 flex items-center justify-center">
<img
src="/assets/favicon.svg"
src={
settings?.favicon
? `${settings.favicon}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`
: "/assets/favicon.svg"
}
alt="PatchMon Logo"
className="h-16 w-16"
onError={(e) => {
e.target.src = "/assets/favicon.svg";
}}
/>
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900 dark:text-secondary-100">
+116 -45
View File
@@ -10,6 +10,7 @@ import {
Eye as EyeIcon,
EyeOff as EyeOffIcon,
GripVertical,
Info,
Package,
RefreshCw,
Search,
@@ -29,6 +30,7 @@ const Packages = () => {
const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false);
const [descriptionModal, setDescriptionModal] = useState(null); // { packageName, description }
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem("packages-page-size");
@@ -168,13 +170,18 @@ const Packages = () => {
}, [packagesResponse]);
// Fetch dashboard stats for card counts (consistent with homepage)
const { data: dashboardStats } = useQuery({
const { data: dashboardStats, refetch: refetchDashboardStats } = useQuery({
queryKey: ["dashboardStats"],
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Handle refresh - refetch all related data
const handleRefresh = async () => {
await Promise.all([refetch(), refetchDashboardStats()]);
};
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ["hosts"],
@@ -363,28 +370,41 @@ const Packages = () => {
switch (column.id) {
case "name":
return (
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
>
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.name}
<div className="flex items-center text-left w-full">
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group flex-1"
>
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.name}
</div>
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
{pkg.description && (
<div className="text-sm text-secondary-600 dark:text-secondary-400">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</button>
</button>
{pkg.description && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDescriptionModal({
packageName: pkg.name,
description: pkg.description,
});
}}
className="ml-1 flex-shrink-0 p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors"
title="View description"
>
<Info className="h-4 w-4 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300" />
</button>
)}
</div>
);
case "packageHosts": {
// Show total number of hosts where this package is installed
@@ -526,17 +546,17 @@ const Packages = () => {
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Packages
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
<p className="text-sm text-secondary-600 dark:text-white mt-1">
Manage package updates and security patches
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => refetch()}
onClick={handleRefresh}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh packages data"
title="Refresh packages and statistics data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
@@ -755,27 +775,36 @@ const Packages = () => {
{paginatedPackages.map((pkg) => (
<div key={pkg.id} className="card p-4 space-y-3">
{/* Package Name */}
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="text-left w-full"
>
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
{pkg.name}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="text-left flex-1"
>
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
{pkg.name}
</div>
</div>
</div>
</button>
{/* Description (only rendered if a description exists) */}
{pkg.description && (
<div className="text-sm text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-800/50 rounded-md p-2 border border-secondary-200 dark:border-secondary-700/50">
<div className="line-clamp-2 whitespace-normal">
{pkg.description}
</div>
</div>
)}
</button>
{pkg.description && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDescriptionModal({
packageName: pkg.name,
description: pkg.description,
});
}}
className="flex-shrink-0 p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors"
title="View description"
>
<Info className="h-4 w-4 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300" />
</button>
)}
</div>
{/* Status and Hosts on same line */}
<div className="flex items-center justify-between gap-2">
@@ -961,6 +990,48 @@ const Packages = () => {
onReset={resetColumns}
/>
)}
{/* Description Modal */}
{descriptionModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<button
type="button"
onClick={() => setDescriptionModal(null)}
className="fixed inset-0 cursor-default"
aria-label="Close modal"
/>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-lg w-full mx-4 relative z-10">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{descriptionModal.packageName}
</h3>
<button
type="button"
onClick={() => setDescriptionModal(null)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-6 py-4">
<p className="text-sm text-secondary-700 dark:text-secondary-300 whitespace-pre-wrap">
{descriptionModal.description}
</p>
</div>
<div className="px-6 py-4 border-t border-secondary-200 dark:border-secondary-600 flex justify-end">
<button
type="button"
onClick={() => setDescriptionModal(null)}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};