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.

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,
});
}
},
);

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",

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

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:

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;

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)",

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

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"]');

View File

@@ -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) => {

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" />

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}

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">

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>
);
};