mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-10 17:05:48 +00:00
fix: Resolve all linting errors
- Remove unused imports and variables in metricsRoutes.js - Prefix unused error variables with underscore - Fix useEffect dependency in Login.jsx - Add aria-label and title to all SVG elements for accessibility
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
-- Add color_theme field to settings table for customizable app theming
|
||||
ALTER TABLE "settings" ADD COLUMN "color_theme" TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AddMetricsTelemetry
|
||||
-- Add anonymous metrics and telemetry fields to settings table
|
||||
|
||||
-- Add metrics fields to settings table
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_anonymous_id" TEXT;
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_last_sent" TIMESTAMP(3);
|
||||
|
||||
-- Generate UUID for existing records (if any exist)
|
||||
-- This will use PostgreSQL's gen_random_uuid() function
|
||||
UPDATE "settings"
|
||||
SET "metrics_anonymous_id" = gen_random_uuid()::text
|
||||
WHERE "metrics_anonymous_id" IS NULL;
|
||||
|
||||
@@ -170,27 +170,31 @@ model role_permissions {
|
||||
}
|
||||
|
||||
model settings {
|
||||
id String @id
|
||||
server_url String @default("http://localhost:3001")
|
||||
server_protocol String @default("http")
|
||||
server_host String @default("localhost")
|
||||
server_port Int @default(3001)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
update_interval Int @default(60)
|
||||
auto_update Boolean @default(false)
|
||||
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
|
||||
ssh_key_path String?
|
||||
repository_type String @default("public")
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
id String @id
|
||||
server_url String @default("http://localhost:3001")
|
||||
server_protocol String @default("http")
|
||||
server_host String @default("localhost")
|
||||
server_port Int @default(3001)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
update_interval Int @default(60)
|
||||
auto_update Boolean @default(false)
|
||||
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
|
||||
ssh_key_path String?
|
||||
repository_type String @default("public")
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
metrics_enabled Boolean @default(true)
|
||||
metrics_anonymous_id String?
|
||||
metrics_last_sent DateTime?
|
||||
color_theme String @default("default")
|
||||
}
|
||||
|
||||
model update_history {
|
||||
|
||||
@@ -755,10 +755,10 @@ router.post("/../integrations/docker", async (req, res) => {
|
||||
containers,
|
||||
images,
|
||||
updates,
|
||||
daemon_info,
|
||||
daemon_info: _daemon_info,
|
||||
hostname,
|
||||
machine_id,
|
||||
agent_version,
|
||||
agent_version: _agent_version,
|
||||
} = req.body;
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -366,13 +366,10 @@ router.post(
|
||||
) {
|
||||
console.error("⚠️ DATABASE CONNECTION POOL EXHAUSTED!");
|
||||
console.error(
|
||||
"⚠️ Current limit: DB_CONNECTION_LIMIT=" +
|
||||
(process.env.DB_CONNECTION_LIMIT || "30"),
|
||||
`⚠️ Current limit: DB_CONNECTION_LIMIT=${process.env.DB_CONNECTION_LIMIT || "30"}`,
|
||||
);
|
||||
console.error(
|
||||
"⚠️ Pool timeout: DB_POOL_TIMEOUT=" +
|
||||
(process.env.DB_POOL_TIMEOUT || "20") +
|
||||
"s",
|
||||
`⚠️ Pool timeout: DB_POOL_TIMEOUT=${process.env.DB_POOL_TIMEOUT || "20"}s`,
|
||||
);
|
||||
console.error(
|
||||
"⚠️ Suggestion: Increase DB_CONNECTION_LIMIT in your .env file",
|
||||
|
||||
@@ -14,10 +14,10 @@ router.post("/docker", async (req, res) => {
|
||||
containers,
|
||||
images,
|
||||
updates,
|
||||
daemon_info,
|
||||
daemon_info: _daemon_info,
|
||||
hostname,
|
||||
machine_id,
|
||||
agent_version,
|
||||
agent_version: _agent_version,
|
||||
} = req.body;
|
||||
|
||||
console.log(
|
||||
|
||||
148
backend/src/routes/metricsRoutes.js
Normal file
148
backend/src/routes/metricsRoutes.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { getSettings, updateSettings } = require("../services/settingsService");
|
||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get metrics settings
|
||||
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
// Generate anonymous ID if it doesn't exist
|
||||
if (!settings.metrics_anonymous_id) {
|
||||
const anonymousId = uuidv4();
|
||||
await updateSettings(settings.id, {
|
||||
metrics_anonymous_id: anonymousId,
|
||||
});
|
||||
settings.metrics_anonymous_id = anonymousId;
|
||||
}
|
||||
|
||||
res.json({
|
||||
metrics_enabled: settings.metrics_enabled ?? true,
|
||||
metrics_anonymous_id: settings.metrics_anonymous_id,
|
||||
metrics_last_sent: settings.metrics_last_sent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Metrics settings fetch error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch metrics settings" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update metrics settings
|
||||
router.put(
|
||||
"/",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
[
|
||||
body("metrics_enabled")
|
||||
.isBoolean()
|
||||
.withMessage("Metrics enabled must be a boolean"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { metrics_enabled } = req.body;
|
||||
const settings = await getSettings();
|
||||
|
||||
await updateSettings(settings.id, {
|
||||
metrics_enabled,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Metrics ${metrics_enabled ? "enabled" : "disabled"} by user`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Metrics settings updated successfully",
|
||||
metrics_enabled,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Metrics settings update error:", error);
|
||||
res.status(500).json({ error: "Failed to update metrics settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Regenerate anonymous ID
|
||||
router.post(
|
||||
"/regenerate-id",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const newAnonymousId = uuidv4();
|
||||
|
||||
await updateSettings(settings.id, {
|
||||
metrics_anonymous_id: newAnonymousId,
|
||||
});
|
||||
|
||||
console.log("Anonymous ID regenerated");
|
||||
|
||||
res.json({
|
||||
message: "Anonymous ID regenerated successfully",
|
||||
metrics_anonymous_id: newAnonymousId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Anonymous ID regeneration error:", error);
|
||||
res.status(500).json({ error: "Failed to regenerate anonymous ID" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Manually send metrics now
|
||||
router.post(
|
||||
"/send-now",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
if (!settings.metrics_enabled) {
|
||||
return res.status(400).json({
|
||||
error: "Metrics are disabled. Please enable metrics first.",
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger metrics directly (no queue delay for manual trigger)
|
||||
const metricsReporting =
|
||||
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
|
||||
const result = await metricsReporting.process(
|
||||
{ name: "manual-send" },
|
||||
false,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ Manual metrics sent successfully");
|
||||
res.json({
|
||||
message: "Metrics sent successfully",
|
||||
data: result,
|
||||
});
|
||||
} else {
|
||||
console.error("❌ Failed to send metrics:", result);
|
||||
res.status(500).json({
|
||||
error: "Failed to send metrics",
|
||||
details: result.reason || result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Send metrics error:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to send metrics",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -158,6 +158,7 @@ router.put(
|
||||
logoDark,
|
||||
logoLight,
|
||||
favicon,
|
||||
colorTheme,
|
||||
} = req.body;
|
||||
|
||||
// Get current settings to check for update interval changes
|
||||
@@ -189,6 +190,7 @@ router.put(
|
||||
if (logoDark !== undefined) updateData.logo_dark = logoDark;
|
||||
if (logoLight !== undefined) updateData.logo_light = logoLight;
|
||||
if (favicon !== undefined) updateData.favicon = favicon;
|
||||
if (colorTheme !== undefined) updateData.color_theme = colorTheme;
|
||||
|
||||
const updatedSettings = await updateSettings(
|
||||
currentSettings.id,
|
||||
|
||||
@@ -69,6 +69,7 @@ const dockerRoutes = require("./routes/dockerRoutes");
|
||||
const integrationRoutes = require("./routes/integrationRoutes");
|
||||
const wsRoutes = require("./routes/wsRoutes");
|
||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||
const metricsRoutes = require("./routes/metricsRoutes");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { queueManager } = require("./services/automation");
|
||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
||||
@@ -475,6 +476,7 @@ app.use(`/api/${apiVersion}/docker`, dockerRoutes);
|
||||
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
|
||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||
|
||||
// Bull Board - will be populated after queue manager initializes
|
||||
let bullBoardRouter = null;
|
||||
@@ -1200,6 +1202,15 @@ async function startServer() {
|
||||
initAgentWs(server, prisma);
|
||||
await agentVersionService.initialize();
|
||||
|
||||
// Send metrics on startup (silent - no console output)
|
||||
try {
|
||||
const metricsReporting =
|
||||
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
|
||||
await metricsReporting.sendSilent();
|
||||
} catch (_error) {
|
||||
// Silent failure - don't block server startup if metrics fail
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
|
||||
@@ -272,7 +272,7 @@ function subscribeToConnectionChanges(apiId, callback) {
|
||||
// Handle Docker container status events from agent
|
||||
async function handleDockerStatusEvent(apiId, message) {
|
||||
try {
|
||||
const { event, container_id, name, status, timestamp } = message;
|
||||
const { event: _event, container_id, name, status, timestamp } = message;
|
||||
|
||||
console.log(
|
||||
`[Docker Event] ${apiId}: Container ${name} (${container_id}) - ${status}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ const SessionCleanup = require("./sessionCleanup");
|
||||
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
|
||||
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
|
||||
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
|
||||
const MetricsReporting = require("./metricsReporting");
|
||||
|
||||
// Queue names
|
||||
const QUEUE_NAMES = {
|
||||
@@ -17,6 +18,7 @@ const QUEUE_NAMES = {
|
||||
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
|
||||
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
|
||||
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
|
||||
METRICS_REPORTING: "metrics-reporting",
|
||||
AGENT_COMMANDS: "agent-commands",
|
||||
};
|
||||
|
||||
@@ -95,6 +97,9 @@ class QueueManager {
|
||||
new OrphanedPackageCleanup(this);
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
|
||||
new DockerInventoryCleanup(this);
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
|
||||
this,
|
||||
);
|
||||
|
||||
console.log("✅ All automation classes initialized");
|
||||
}
|
||||
@@ -162,6 +167,15 @@ class QueueManager {
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Metrics Reporting Worker
|
||||
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
|
||||
QUEUE_NAMES.METRICS_REPORTING,
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind(
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING],
|
||||
),
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Agent Commands Worker
|
||||
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
|
||||
QUEUE_NAMES.AGENT_COMMANDS,
|
||||
@@ -219,6 +233,7 @@ class QueueManager {
|
||||
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +263,10 @@ class QueueManager {
|
||||
].triggerManual();
|
||||
}
|
||||
|
||||
async triggerMetricsReporting() {
|
||||
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
|
||||
175
backend/src/services/automation/metricsReporting.js
Normal file
175
backend/src/services/automation/metricsReporting.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const axios = require("axios");
|
||||
const { prisma } = require("./shared/prisma");
|
||||
const {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
} = require("../../services/settingsService");
|
||||
|
||||
const METRICS_API_URL =
|
||||
process.env.METRICS_API_URL || "https://metrics.patchmon.cloud";
|
||||
|
||||
/**
|
||||
* Metrics Reporting Automation
|
||||
* Sends anonymous usage metrics every 24 hours
|
||||
*/
|
||||
class MetricsReporting {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "metrics-reporting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metrics reporting job
|
||||
*/
|
||||
async process(_job, silent = false) {
|
||||
const startTime = Date.now();
|
||||
if (!silent) console.log("📊 Starting metrics reporting...");
|
||||
|
||||
try {
|
||||
// Fetch fresh settings directly from database (bypass cache)
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: "desc" },
|
||||
});
|
||||
|
||||
// Check if metrics are enabled
|
||||
if (settings.metrics_enabled !== true) {
|
||||
if (!silent) console.log("📊 Metrics reporting is disabled");
|
||||
return { success: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
// Check if we have an anonymous ID
|
||||
if (!settings.metrics_anonymous_id) {
|
||||
if (!silent) console.log("📊 No anonymous ID found, skipping metrics");
|
||||
return { success: false, reason: "no_id" };
|
||||
}
|
||||
|
||||
// Get host count
|
||||
const hostCount = await prisma.hosts.count();
|
||||
|
||||
// Get version
|
||||
const packageJson = require("../../../package.json");
|
||||
const version = packageJson.version;
|
||||
|
||||
// Prepare metrics data
|
||||
const metricsData = {
|
||||
anonymous_id: settings.metrics_anonymous_id,
|
||||
host_count: hostCount,
|
||||
version,
|
||||
};
|
||||
|
||||
if (!silent)
|
||||
console.log(
|
||||
`📊 Sending metrics: ${hostCount} hosts, version ${version}`,
|
||||
);
|
||||
|
||||
// Send to metrics API
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${METRICS_API_URL}/metrics/submit`,
|
||||
metricsData,
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Update last sent timestamp
|
||||
await updateSettings(settings.id, {
|
||||
metrics_last_sent: new Date(),
|
||||
});
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.log(
|
||||
`✅ Metrics sent successfully in ${executionTime}ms:`,
|
||||
response.data,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
hostCount,
|
||||
version,
|
||||
executionTime,
|
||||
};
|
||||
} catch (apiError) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.error(
|
||||
`❌ Failed to send metrics to API after ${executionTime}ms:`,
|
||||
apiError.message,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
reason: "api_error",
|
||||
error: apiError.message,
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.error(
|
||||
`❌ Error in metrics reporting after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
// Don't throw on silent mode, just return failure
|
||||
if (silent) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "error",
|
||||
error: error.message,
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring metrics reporting (daily at 2 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"metrics-reporting",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
|
||||
jobId: "metrics-reporting-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Metrics reporting scheduled (daily at 2 AM)");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual metrics reporting
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"metrics-reporting-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual metrics reporting triggered");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send metrics immediately (silent mode)
|
||||
* Used for automatic sending on server startup
|
||||
*/
|
||||
async sendSilent() {
|
||||
try {
|
||||
const result = await this.process({ name: "startup-silent" }, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Silent failure on startup
|
||||
return { success: false, reason: "error", error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MetricsReporting;
|
||||
@@ -27,7 +27,8 @@
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.30.1"
|
||||
"react-router-dom": "^6.30.1",
|
||||
"trianglify": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.14",
|
||||
|
||||
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import SettingsLayout from "./components/SettingsLayout";
|
||||
import { isAuthPhase } from "./constants/authPhases";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||
|
||||
@@ -41,6 +42,7 @@ const SettingsServerConfig = lazy(
|
||||
() => import("./pages/settings/SettingsServerConfig"),
|
||||
);
|
||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
|
||||
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
|
||||
|
||||
// Loading fallback component
|
||||
const LoadingFallback = () => (
|
||||
@@ -388,6 +390,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/metrics"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsMetrics />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/options"
|
||||
element={
|
||||
@@ -416,13 +428,15 @@ function AppRoutes() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
<ColorThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ColorThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaYoutube } from "react-icons/fa";
|
||||
import { FaReddit, FaYoutube } from "react-icons/fa";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import trianglify from "trianglify";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||
import DiscordIcon from "./DiscordIcon";
|
||||
@@ -61,7 +63,9 @@ const Layout = ({ children }) => {
|
||||
canManageSettings,
|
||||
} = useAuth();
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
const { themeConfig } = useColorTheme();
|
||||
const userMenuRef = useRef(null);
|
||||
const bgCanvasRef = useRef(null);
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
const {
|
||||
@@ -233,27 +237,103 @@ const Layout = ({ children }) => {
|
||||
navigate("/hosts?action=add");
|
||||
};
|
||||
|
||||
// Generate Trianglify background for dark mode
|
||||
useEffect(() => {
|
||||
const generateBackground = () => {
|
||||
if (
|
||||
bgCanvasRef.current &&
|
||||
themeConfig?.login &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
) {
|
||||
// Get current date as seed for daily variation
|
||||
const today = new Date();
|
||||
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||
|
||||
// Generate pattern with selected theme configuration
|
||||
const pattern = trianglify({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
cellSize: themeConfig.login.cellSize,
|
||||
variance: themeConfig.login.variance,
|
||||
seed: dateSeed,
|
||||
xColors: themeConfig.login.xColors,
|
||||
yColors: themeConfig.login.yColors,
|
||||
});
|
||||
|
||||
// Render to canvas
|
||||
pattern.toCanvas(bgCanvasRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
generateBackground();
|
||||
|
||||
// Regenerate on window resize or theme change
|
||||
const handleResize = () => {
|
||||
generateBackground();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
generateBackground();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [themeConfig]);
|
||||
|
||||
// Fetch GitHub stars count
|
||||
const fetchGitHubStars = useCallback(async () => {
|
||||
// Skip if already fetched recently
|
||||
// Try to load cached star count first
|
||||
const cachedStars = localStorage.getItem("githubStarsCount");
|
||||
if (cachedStars) {
|
||||
setGithubStars(parseInt(cachedStars, 10));
|
||||
}
|
||||
|
||||
// Skip API call if fetched recently
|
||||
const lastFetch = localStorage.getItem("githubStarsFetchTime");
|
||||
const now = Date.now();
|
||||
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
|
||||
// 15 minute cache
|
||||
if (lastFetch && now - parseInt(lastFetch, 10) < 600000) {
|
||||
// 10 minute cache
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/9technologygroup/patchmon.net",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGithubStars(data.stargazers_count);
|
||||
localStorage.setItem(
|
||||
"githubStarsCount",
|
||||
data.stargazers_count.toString(),
|
||||
);
|
||||
localStorage.setItem("githubStarsFetchTime", now.toString());
|
||||
} else if (response.status === 403 || response.status === 429) {
|
||||
console.warn("GitHub API rate limit exceeded, using cached value");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch GitHub stars:", error);
|
||||
// Keep using cached value if available
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -303,11 +383,76 @@ const Layout = ({ children }) => {
|
||||
fetchGitHubStars();
|
||||
}, [fetchGitHubStars]);
|
||||
|
||||
// Set CSS custom properties for glassmorphism and theme colors in dark mode
|
||||
useEffect(() => {
|
||||
const updateThemeStyles = () => {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const root = document.documentElement;
|
||||
|
||||
if (isDark && themeConfig?.app) {
|
||||
// Glass navigation bars - very light for pattern visibility
|
||||
root.style.setProperty("--sidebar-bg", "rgba(0, 0, 0, 0.15)");
|
||||
root.style.setProperty("--sidebar-blur", "blur(12px)");
|
||||
root.style.setProperty("--topbar-bg", "rgba(0, 0, 0, 0.15)");
|
||||
root.style.setProperty("--topbar-blur", "blur(12px)");
|
||||
root.style.setProperty("--button-bg", "rgba(255, 255, 255, 0.15)");
|
||||
root.style.setProperty("--button-blur", "blur(8px)");
|
||||
|
||||
// Theme-colored cards and buttons - darker to stand out
|
||||
root.style.setProperty("--card-bg", themeConfig.app.cardBg);
|
||||
root.style.setProperty("--card-border", themeConfig.app.cardBorder);
|
||||
root.style.setProperty("--card-bg-hover", themeConfig.app.bgTertiary);
|
||||
root.style.setProperty("--theme-button-bg", themeConfig.app.buttonBg);
|
||||
root.style.setProperty(
|
||||
"--theme-button-hover",
|
||||
themeConfig.app.buttonHover,
|
||||
);
|
||||
} else {
|
||||
// Light mode - standard colors
|
||||
root.style.setProperty("--sidebar-bg", "white");
|
||||
root.style.setProperty("--sidebar-blur", "none");
|
||||
root.style.setProperty("--topbar-bg", "white");
|
||||
root.style.setProperty("--topbar-blur", "none");
|
||||
root.style.setProperty("--button-bg", "white");
|
||||
root.style.setProperty("--button-blur", "none");
|
||||
root.style.setProperty("--card-bg", "white");
|
||||
root.style.setProperty("--card-border", "#e5e7eb");
|
||||
root.style.setProperty("--card-bg-hover", "#f9fafb");
|
||||
root.style.setProperty("--theme-button-bg", "#f3f4f6");
|
||||
root.style.setProperty("--theme-button-hover", "#e5e7eb");
|
||||
}
|
||||
};
|
||||
|
||||
updateThemeStyles();
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver(() => {
|
||||
updateThemeStyles();
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [themeConfig]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
<div className="min-h-screen bg-secondary-50 dark:bg-black relative overflow-hidden">
|
||||
{/* Full-screen Trianglify Background (Dark Mode Only) */}
|
||||
<canvas
|
||||
ref={bgCanvasRef}
|
||||
className="fixed inset-0 w-full h-full hidden dark:block"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-br from-black/10 to-black/20 hidden dark:block pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{/* Mobile sidebar */}
|
||||
<div
|
||||
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,7 +460,14 @@ const Layout = ({ children }) => {
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
<div className="relative flex w-full max-w-[280px] flex-col bg-white dark:bg-secondary-800 pb-4 pt-5 shadow-xl">
|
||||
<div
|
||||
className="relative flex w-full max-w-[280px] flex-col bg-white dark:border-r dark:border-white/10 pb-4 pt-5 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: "var(--sidebar-bg, white)",
|
||||
backdropFilter: "var(--sidebar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--sidebar-blur, none)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -534,17 +686,43 @@ const Layout = ({ children }) => {
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div
|
||||
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||
className={`hidden lg:fixed lg:inset-y-0 z-[100] lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
|
||||
} bg-white dark:bg-secondary-800`}
|
||||
} bg-white dark:bg-transparent`}
|
||||
>
|
||||
{/* Collapse/Expand button on border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute top-5 -right-3 z-[200] flex items-center justify-center w-6 h-6 rounded-full bg-white border border-secondary-300 dark:border-white/20 shadow-md hover:bg-secondary-50 transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--button-bg, white)",
|
||||
backdropFilter: "var(--button-blur, none)",
|
||||
WebkitBackdropFilter: "var(--button-blur, none)",
|
||||
}}
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
className={`flex grow flex-col gap-y-5 border-r border-secondary-200 dark:border-white/10 bg-white ${
|
||||
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: "var(--sidebar-bg, white)",
|
||||
backdropFilter: "var(--sidebar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--sidebar-blur, none)",
|
||||
overflowY: "auto",
|
||||
overflowX: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-white/10 ${
|
||||
sidebarCollapsed ? "justify-center" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
@@ -562,19 +740,6 @@ const Layout = ({ children }) => {
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{/* Collapse/Expand button on border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
)}
|
||||
</button>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul className="flex flex-1 flex-col gap-y-6">
|
||||
{/* Show message for users with very limited permissions */}
|
||||
@@ -930,12 +1095,19 @@ const Layout = ({ children }) => {
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 relative z-10 ${
|
||||
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
|
||||
}`}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className="sticky top-0 z-[90] flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-white/10 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
|
||||
style={{
|
||||
backgroundColor: "var(--topbar-bg, white)",
|
||||
backdropFilter: "var(--topbar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--topbar-blur, none)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden"
|
||||
@@ -987,8 +1159,8 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Github className="h-5 w-5 flex-shrink-0" />
|
||||
{githubStars !== null && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="h-3 w-3 fill-current text-yellow-500" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current text-yellow-500" />
|
||||
<span className="text-sm font-medium">{githubStars}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1059,7 +1231,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<FaYoutube className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 7) Web */}
|
||||
{/* 8) Reddit */}
|
||||
<a
|
||||
href="https://www.reddit.com/r/patchmon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Reddit Community"
|
||||
>
|
||||
<FaReddit className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 9) Web */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
@@ -1074,7 +1256,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-transparent">
|
||||
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => {
|
||||
href: "/settings/server-version",
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
name: "Metrics",
|
||||
href: "/settings/metrics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Image,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
|
||||
import { settingsAPI } from "../../utils/api";
|
||||
|
||||
const BrandingTab = () => {
|
||||
@@ -12,6 +20,7 @@ const BrandingTab = () => {
|
||||
});
|
||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -75,6 +84,22 @@ const BrandingTab = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Theme update mutation
|
||||
const updateThemeMutation = useMutation({
|
||||
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
|
||||
onSuccess: (_data, theme) => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setColorTheme(theme);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Update theme error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleThemeChange = (theme) => {
|
||||
updateThemeMutation.mutate(theme);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -102,17 +127,110 @@ const BrandingTab = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos, favicon, and
|
||||
color themes. These will be displayed throughout the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Selector */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Color Theme
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Choose a color theme that will be applied to the login page and
|
||||
background areas throughout the app.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
|
||||
const isSelected = colorTheme === themeKey;
|
||||
const gradientColors = theme.login.xColors;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={themeKey}
|
||||
type="button"
|
||||
onClick={() => handleThemeChange(themeKey)}
|
||||
disabled={updateThemeMutation.isPending}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
|
||||
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
|
||||
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{/* Theme Preview */}
|
||||
<div
|
||||
className="h-20 rounded-md mb-3 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Theme Name */}
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
|
||||
{theme.name}
|
||||
</div>
|
||||
|
||||
{/* Selected Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-label="Selected theme"
|
||||
>
|
||||
<title>Selected</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{updateThemeMutation.isPending && (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Updating theme...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateThemeMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
Failed to update theme: {updateThemeMutation.error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Section Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Logos
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos and favicon.
|
||||
These will be displayed throughout the application.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Dark Logo */}
|
||||
|
||||
194
frontend/src/contexts/ColorThemeContext.jsx
Normal file
194
frontend/src/contexts/ColorThemeContext.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
const ColorThemeContext = createContext();
|
||||
|
||||
// Theme configurations matching the login backgrounds
|
||||
export const THEME_PRESETS = {
|
||||
default: {
|
||||
name: "Normal Dark",
|
||||
login: {
|
||||
cellSize: 90,
|
||||
variance: 0.85,
|
||||
xColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
|
||||
yColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#1e293b",
|
||||
bgSecondary: "#1e293b",
|
||||
bgTertiary: "#334155",
|
||||
borderColor: "#475569",
|
||||
cardBg: "#1e293b",
|
||||
cardBorder: "#334155",
|
||||
buttonBg: "#334155",
|
||||
buttonHover: "#475569",
|
||||
},
|
||||
},
|
||||
cyber_blue: {
|
||||
name: "Cyber Blue",
|
||||
login: {
|
||||
cellSize: 90,
|
||||
variance: 0.85,
|
||||
xColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
|
||||
yColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#0a0820",
|
||||
bgSecondary: "#1a1f3a",
|
||||
bgTertiary: "#2d3561",
|
||||
borderColor: "#4a5584",
|
||||
cardBg: "#1a1f3a",
|
||||
cardBorder: "#2d3561",
|
||||
buttonBg: "#2d3561",
|
||||
buttonHover: "#4a5584",
|
||||
},
|
||||
},
|
||||
neon_purple: {
|
||||
name: "Neon Purple",
|
||||
login: {
|
||||
cellSize: 80,
|
||||
variance: 0.9,
|
||||
xColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
|
||||
yColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#0f0a1e",
|
||||
bgSecondary: "#1e0f3e",
|
||||
bgTertiary: "#4a0082",
|
||||
borderColor: "#7209b7",
|
||||
cardBg: "#1e0f3e",
|
||||
cardBorder: "#4a0082",
|
||||
buttonBg: "#4a0082",
|
||||
buttonHover: "#7209b7",
|
||||
},
|
||||
},
|
||||
matrix_green: {
|
||||
name: "Matrix Green",
|
||||
login: {
|
||||
cellSize: 70,
|
||||
variance: 0.7,
|
||||
xColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
|
||||
yColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#001a00",
|
||||
bgSecondary: "#003300",
|
||||
bgTertiary: "#004d00",
|
||||
borderColor: "#006600",
|
||||
cardBg: "#003300",
|
||||
cardBorder: "#004d00",
|
||||
buttonBg: "#004d00",
|
||||
buttonHover: "#006600",
|
||||
},
|
||||
},
|
||||
ocean_blue: {
|
||||
name: "Ocean Blue",
|
||||
login: {
|
||||
cellSize: 85,
|
||||
variance: 0.8,
|
||||
xColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
|
||||
yColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#001845",
|
||||
bgSecondary: "#023e7d",
|
||||
bgTertiary: "#0077b6",
|
||||
borderColor: "#0096c7",
|
||||
cardBg: "#023e7d",
|
||||
cardBorder: "#0077b6",
|
||||
buttonBg: "#0077b6",
|
||||
buttonHover: "#0096c7",
|
||||
},
|
||||
},
|
||||
sunset_gradient: {
|
||||
name: "Sunset Gradient",
|
||||
login: {
|
||||
cellSize: 95,
|
||||
variance: 0.75,
|
||||
xColors: ["#1a0033", "#330066", "#4d0099", "#6600cc", "#9933ff"],
|
||||
yColors: ["#1a0033", "#660033", "#990033", "#cc0066", "#ff0099"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#1a0033",
|
||||
bgSecondary: "#330066",
|
||||
bgTertiary: "#4d0099",
|
||||
borderColor: "#6600cc",
|
||||
cardBg: "#330066",
|
||||
cardBorder: "#4d0099",
|
||||
buttonBg: "#4d0099",
|
||||
buttonHover: "#6600cc",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorThemeProvider = ({ children }) => {
|
||||
const [colorTheme, setColorTheme] = useState("default");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fetch theme from settings on mount
|
||||
useEffect(() => {
|
||||
const fetchTheme = async () => {
|
||||
try {
|
||||
// Check localStorage first for unauthenticated pages (login)
|
||||
const cachedTheme = localStorage.getItem("colorTheme");
|
||||
if (cachedTheme) {
|
||||
setColorTheme(cachedTheme);
|
||||
}
|
||||
|
||||
// Try to fetch from API (will fail on login page, that's ok)
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const response = await fetch("/api/v1/settings", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.color_theme) {
|
||||
setColorTheme(data.color_theme);
|
||||
localStorage.setItem("colorTheme", data.color_theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_apiError) {
|
||||
// Silent fail - use cached or default theme
|
||||
console.log("Could not fetch theme from API, using cached/default");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading color theme:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTheme();
|
||||
}, []);
|
||||
|
||||
const updateColorTheme = (theme) => {
|
||||
setColorTheme(theme);
|
||||
localStorage.setItem("colorTheme", theme);
|
||||
};
|
||||
|
||||
const value = {
|
||||
colorTheme,
|
||||
setColorTheme: updateColorTheme,
|
||||
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<ColorThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ColorThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColorTheme = () => {
|
||||
const context = useContext(ColorThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useColorTheme must be used within ColorThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
@apply bg-secondary-50 dark:bg-transparent text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +39,46 @@
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
@apply btn border-secondary-300 text-secondary-700 bg-white hover:bg-secondary-50 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.dark .btn-outline {
|
||||
background-color: var(--theme-button-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dark .btn-outline:hover {
|
||||
background-color: var(--theme-button-hover, #334155);
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
|
||||
@apply bg-white rounded-lg shadow-card border border-secondary-200;
|
||||
}
|
||||
|
||||
.dark .card {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:shadow-card-hover transition-shadow duration-150;
|
||||
@apply card transition-all duration-150;
|
||||
}
|
||||
|
||||
.dark .card-hover:hover {
|
||||
background-color: var(--card-bg-hover, #334155);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white text-secondary-900;
|
||||
}
|
||||
|
||||
.dark .input {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -84,6 +111,27 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Theme-aware backgrounds for general elements */
|
||||
.dark .bg-secondary-800 {
|
||||
background-color: var(--card-bg, #1e293b) !important;
|
||||
}
|
||||
|
||||
.dark .bg-secondary-700 {
|
||||
background-color: var(--card-bg-hover, #334155) !important;
|
||||
}
|
||||
|
||||
.dark .bg-secondary-900 {
|
||||
background-color: var(--theme-button-bg, #1e293b) !important;
|
||||
}
|
||||
|
||||
.dark .border-secondary-600 {
|
||||
border-color: var(--card-border, #334155) !important;
|
||||
}
|
||||
|
||||
.dark .border-secondary-700 {
|
||||
border-color: var(--theme-button-hover, #475569) !important;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
Globe,
|
||||
Lock,
|
||||
Mail,
|
||||
Smartphone,
|
||||
Route,
|
||||
Star,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { FaReddit, FaYoutube } from "react-icons/fa";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import trianglify from "trianglify";
|
||||
import DiscordIcon from "../components/DiscordIcon";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { authAPI, isCorsError } from "../utils/api";
|
||||
|
||||
const Login = () => {
|
||||
@@ -42,9 +50,48 @@ const Login = () => {
|
||||
const [requiresTfa, setRequiresTfa] = useState(false);
|
||||
const [tfaUsername, setTfaUsername] = useState("");
|
||||
const [signupEnabled, setSignupEnabled] = useState(false);
|
||||
const [latestRelease, setLatestRelease] = useState(null);
|
||||
const [githubStars, setGithubStars] = useState(null);
|
||||
const canvasRef = useRef(null);
|
||||
const { themeConfig } = useColorTheme();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Generate Trianglify background based on selected theme
|
||||
useEffect(() => {
|
||||
const generateBackground = () => {
|
||||
if (canvasRef.current && themeConfig?.login) {
|
||||
// Get current date as seed for daily variation
|
||||
const today = new Date();
|
||||
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||
|
||||
// Generate pattern with selected theme configuration
|
||||
const pattern = trianglify({
|
||||
width: canvasRef.current.offsetWidth,
|
||||
height: canvasRef.current.offsetHeight,
|
||||
cellSize: themeConfig.login.cellSize,
|
||||
variance: themeConfig.login.variance,
|
||||
seed: dateSeed,
|
||||
xColors: themeConfig.login.xColors,
|
||||
yColors: themeConfig.login.yColors,
|
||||
});
|
||||
|
||||
// Render to canvas
|
||||
pattern.toCanvas(canvasRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
generateBackground();
|
||||
|
||||
// Regenerate on window resize
|
||||
const handleResize = () => {
|
||||
generateBackground();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [themeConfig]); // Regenerate when theme changes
|
||||
|
||||
// Check if signup is enabled
|
||||
useEffect(() => {
|
||||
const checkSignupEnabled = async () => {
|
||||
@@ -63,6 +110,99 @@ const Login = () => {
|
||||
checkSignupEnabled();
|
||||
}, []);
|
||||
|
||||
// Fetch latest release and stars from GitHub
|
||||
useEffect(() => {
|
||||
const fetchGitHubData = async () => {
|
||||
try {
|
||||
// Try to get cached data first
|
||||
const cachedRelease = localStorage.getItem("githubLatestRelease");
|
||||
const cachedStars = localStorage.getItem("githubStarsCount");
|
||||
const cacheTime = localStorage.getItem("githubReleaseCacheTime");
|
||||
const now = Date.now();
|
||||
|
||||
// Load cached data immediately
|
||||
if (cachedRelease) {
|
||||
setLatestRelease(JSON.parse(cachedRelease));
|
||||
}
|
||||
if (cachedStars) {
|
||||
setGithubStars(parseInt(cachedStars, 10));
|
||||
}
|
||||
|
||||
// Use cache if less than 1 hour old
|
||||
if (cacheTime && now - parseInt(cacheTime, 10) < 3600000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch repository info (includes star count)
|
||||
const repoResponse = await fetch(
|
||||
"https://api.github.com/repos/PatchMon/PatchMon",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (repoResponse.ok) {
|
||||
const repoData = await repoResponse.json();
|
||||
setGithubStars(repoData.stargazers_count);
|
||||
localStorage.setItem(
|
||||
"githubStarsCount",
|
||||
repoData.stargazers_count.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch latest release
|
||||
const releaseResponse = await fetch(
|
||||
"https://api.github.com/repos/PatchMon/PatchMon/releases/latest",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (releaseResponse.ok) {
|
||||
const data = await releaseResponse.json();
|
||||
const releaseInfo = {
|
||||
version: data.tag_name,
|
||||
name: data.name,
|
||||
publishedAt: new Date(data.published_at).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
),
|
||||
body: data.body?.split("\n").slice(0, 3).join("\n") || "", // First 3 lines
|
||||
};
|
||||
|
||||
setLatestRelease(releaseInfo);
|
||||
localStorage.setItem(
|
||||
"githubLatestRelease",
|
||||
JSON.stringify(releaseInfo),
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem("githubReleaseCacheTime", now.toString());
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch GitHub data:", error);
|
||||
// Set fallback data if nothing cached
|
||||
if (!latestRelease) {
|
||||
setLatestRelease({
|
||||
version: "v1.3.0",
|
||||
name: "Latest Release",
|
||||
publishedAt: "Recently",
|
||||
body: "Monitor and manage your Linux package updates",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchGitHubData();
|
||||
}, [latestRelease]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
@@ -239,312 +379,532 @@ const Login = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||
<Lock size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
||||
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-secondary-600">
|
||||
Monitor and manage your Linux package updates
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-screen relative flex">
|
||||
{/* Full-screen Trianglify Background */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-black/60" />
|
||||
|
||||
{!requiresTfa ? (
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Left side - Info Panel (hidden on mobile) */}
|
||||
<div className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative z-10">
|
||||
<div className="flex flex-col justify-between text-white p-12 h-full w-full">
|
||||
<div className="flex-1 flex flex-col justify-center items-start max-w-xl mx-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={usernameId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
<img
|
||||
src="/assets/logo_dark.png"
|
||||
alt="PatchMon"
|
||||
className="h-16 mb-4"
|
||||
/>
|
||||
<p className="text-sm text-blue-200 font-medium tracking-wide uppercase">
|
||||
Linux Patch Monitoring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{latestRelease ? (
|
||||
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
||||
<span className="text-green-300 text-sm font-semibold">
|
||||
Latest Release
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{latestRelease.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{latestRelease.name && (
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{latestRelease.name}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="Release date"
|
||||
>
|
||||
<title>Release date</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Released {latestRelease.publishedAt}</span>
|
||||
</div>
|
||||
|
||||
{latestRelease.body && (
|
||||
<p className="text-sm text-gray-300 leading-relaxed line-clamp-3">
|
||||
{latestRelease.body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://github.com/PatchMon/PatchMon/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-300 hover:text-blue-200 transition-colors font-medium"
|
||||
>
|
||||
View Release Notes
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="External link"
|
||||
>
|
||||
<title>External link</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-6 bg-white/20 rounded w-3/4" />
|
||||
<div className="h-4 bg-white/20 rounded w-1/2" />
|
||||
<div className="h-4 bg-white/20 rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Links Footer */}
|
||||
<div className="max-w-xl mx-auto w-full">
|
||||
<div className="border-t border-white/10 pt-6">
|
||||
<p className="text-sm text-gray-400 mb-4">Connect with us</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* GitHub */}
|
||||
<a
|
||||
href="https://github.com/PatchMon/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="GitHub Repository"
|
||||
>
|
||||
{isSignupMode ? "Username" : "Username or Email"}
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={usernameId}
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder={
|
||||
isSignupMode
|
||||
? "Enter your username"
|
||||
: "Enter your username or email"
|
||||
}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<User size={20} color="#64748b" strokeWidth={2} />
|
||||
<Github className="h-5 w-5 text-white" />
|
||||
{githubStars !== null && (
|
||||
<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">
|
||||
{githubStars}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* 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-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="Roadmap"
|
||||
>
|
||||
<Route className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
|
||||
{/* Docs */}
|
||||
<a
|
||||
href="https://docs.patchmon.net"
|
||||
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="Documentation"
|
||||
>
|
||||
<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-white" />
|
||||
</a>
|
||||
|
||||
{/* Reddit */}
|
||||
<a
|
||||
href="https://www.reddit.com/r/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="Reddit Community"
|
||||
>
|
||||
<FaReddit className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
|
||||
{/* Website */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
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="Visit patchmon.net"
|
||||
>
|
||||
<Globe className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Login Form */}
|
||||
<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-secondary-900 rounded-2xl shadow-2xl p-8 lg:p-10">
|
||||
<div>
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center">
|
||||
<img
|
||||
src="/assets/favicon.svg"
|
||||
alt="PatchMon Logo"
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
|
||||
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Monitor and manage your Linux package updates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!requiresTfa ? (
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={usernameId}
|
||||
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
{isSignupMode ? "Username" : "Username or Email"}
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={usernameId}
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder={
|
||||
isSignupMode
|
||||
? "Enter your username"
|
||||
: "Enter your username or email"
|
||||
}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<User size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSignupMode && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={firstNameId}
|
||||
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id={firstNameId}
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={lastNameId}
|
||||
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id={lastNameId}
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={emailId}
|
||||
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Mail size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={passwordId}
|
||||
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Lock size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} color="#64748b" strokeWidth={2} />
|
||||
) : (
|
||||
<Eye size={20} color="#64748b" strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSignupMode && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={firstNameId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id={firstNameId}
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your first name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={lastNameId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id={lastNameId}
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your last name"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={emailId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Mail size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={passwordId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Lock size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{isSignupMode ? "Creating account..." : "Signing in..."}
|
||||
</div>
|
||||
) : isSignupMode ? (
|
||||
"Create Account"
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{signupEnabled && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
{isSignupMode
|
||||
? "Already have an account?"
|
||||
: "Don't have an account?"}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 focus:outline-none focus:underline"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} color="#64748b" strokeWidth={2} />
|
||||
) : (
|
||||
<Eye size={20} color="#64748b" strokeWidth={2} />
|
||||
)}
|
||||
{isSignupMode ? "Sign in" : "Sign up"}
|
||||
</button>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{isSignupMode ? "Creating account..." : "Signing in..."}
|
||||
</div>
|
||||
) : isSignupMode ? (
|
||||
"Create Account"
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{signupEnabled && (
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{isSignupMode
|
||||
? "Already have an account?"
|
||||
: "Don't have an account?"}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||
>
|
||||
{isSignupMode ? "Sign in" : "Sign up"}
|
||||
</button>
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center">
|
||||
<img
|
||||
src="/assets/favicon.svg"
|
||||
alt="PatchMon Logo"
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900">
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={tokenId}
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id={tokenId}
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={rememberMeId}
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
checked={tfaData.remember_me}
|
||||
onChange={handleTfaInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor={rememberMeId}
|
||||
className="ml-2 block text-sm text-secondary-700"
|
||||
>
|
||||
Remember me on this computer (skip TFA for 30 days)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={tokenId}
|
||||
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
|
||||
>
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id={tokenId}
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || tfaData.token.length !== 6}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifying...
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={rememberMeId}
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
checked={tfaData.remember_me}
|
||||
onChange={handleTfaInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor={rememberMeId}
|
||||
className="ml-2 block text-sm text-secondary-900 dark:text-secondary-200"
|
||||
>
|
||||
Remember me on this computer (skip TFA for 30 days)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Verify Code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || tfaData.token.length !== 6}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifying...
|
||||
</div>
|
||||
) : (
|
||||
"Verify Code"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={16}
|
||||
className="text-secondary-700 dark:text-secondary-200"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
399
frontend/src/pages/settings/SettingsMetrics.jsx
Normal file
399
frontend/src/pages/settings/SettingsMetrics.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Info,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
|
||||
// API functions - will be added to utils/api.js
|
||||
const metricsAPI = {
|
||||
getSettings: () =>
|
||||
fetch("/api/v1/metrics", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
updateSettings: (data) =>
|
||||
fetch("/api/v1/metrics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}).then((res) => res.json()),
|
||||
regenerateId: () =>
|
||||
fetch("/api/v1/metrics/regenerate-id", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
sendNow: () =>
|
||||
fetch("/api/v1/metrics/send-now", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
};
|
||||
|
||||
const SettingsMetrics = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [showFullId, setShowFullId] = useState(false);
|
||||
|
||||
// Fetch metrics settings
|
||||
const {
|
||||
data: metricsSettings,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["metrics-settings"],
|
||||
queryFn: () => metricsAPI.getSettings(),
|
||||
});
|
||||
|
||||
// Toggle metrics mutation
|
||||
const toggleMetricsMutation = useMutation({
|
||||
mutationFn: (enabled) =>
|
||||
metricsAPI.updateSettings({ metrics_enabled: enabled }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Regenerate ID mutation
|
||||
const regenerateIdMutation = useMutation({
|
||||
mutationFn: () => metricsAPI.regenerateId(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Send now mutation
|
||||
const sendNowMutation = useMutation({
|
||||
mutationFn: () => metricsAPI.sendNow(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error loading metrics settings
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error.message || "Failed to load settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const maskId = (id) => {
|
||||
if (!id) return "";
|
||||
if (showFullId) return id;
|
||||
return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<BarChart3 className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Anonymous Metrics & Telemetry
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Help us understand PatchMon's global usage (100% anonymous)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Information */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-6">
|
||||
<div className="flex">
|
||||
<Shield className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Your Privacy Matters
|
||||
</h3>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>We do NOT collect:</strong> IP addresses, hostnames,
|
||||
system details, or any personally identifiable information
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>We ONLY collect:</strong> An anonymous UUID (for
|
||||
deduplication) and the number of hosts you're monitoring
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Purpose:</strong> Display a live counter on our
|
||||
website showing global PatchMon adoption
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<Globe className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Open Source:</strong> All code is public and
|
||||
auditable on GitHub
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Toggle */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Enable Anonymous Metrics
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Share anonymous usage statistics to help us showcase PatchMon's
|
||||
global adoption. Data is sent automatically every 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled)
|
||||
}
|
||||
disabled={toggleMetricsMutation.isPending}
|
||||
className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
metricsSettings?.metrics_enabled
|
||||
? "bg-primary-600"
|
||||
: "bg-secondary-200 dark:bg-secondary-700"
|
||||
} ${toggleMetricsMutation.isPending ? "opacity-50" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
metricsSettings?.metrics_enabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center text-sm">
|
||||
{metricsSettings?.metrics_enabled ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
Metrics enabled - Thank you for supporting PatchMon!
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4 text-secondary-500 mr-2" />
|
||||
<span className="text-secondary-600 dark:text-secondary-400">
|
||||
Metrics disabled - No data is being sent
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anonymous ID Section */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Your Anonymous Instance ID
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
This UUID identifies your instance without revealing any
|
||||
personal information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-secondary-50 dark:bg-secondary-700 rounded-md p-3 font-mono text-sm break-all">
|
||||
{maskId(metricsSettings?.metrics_anonymous_id)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullId(!showFullId)}
|
||||
className="p-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white"
|
||||
title={showFullId ? "Hide ID" : "Show full ID"}
|
||||
>
|
||||
{showFullId ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenerateIdMutation.mutate()}
|
||||
disabled={regenerateIdMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{regenerateIdMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-secondary-700 dark:border-secondary-200 mr-2"></div>
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Regenerate ID
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendNowMutation.mutate()}
|
||||
disabled={
|
||||
!metricsSettings?.metrics_enabled || sendNowMutation.isPending
|
||||
}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sendNowMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Metrics Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{metricsSettings?.metrics_last_sent && (
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Last sent:{" "}
|
||||
{new Date(metricsSettings.metrics_last_sent).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{regenerateIdMutation.isSuccess && (
|
||||
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
|
||||
<p className="ml-2 text-sm text-green-700 dark:text-green-300">
|
||||
Anonymous ID regenerated successfully
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendNowMutation.isSuccess && (
|
||||
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
|
||||
<div className="ml-2 text-sm text-green-700 dark:text-green-300">
|
||||
<p className="font-medium">Metrics sent successfully!</p>
|
||||
{sendNowMutation.data?.data && (
|
||||
<p className="mt-1">
|
||||
Sent: {sendNowMutation.data.data.hostCount} hosts, version{" "}
|
||||
{sendNowMutation.data.data.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendNowMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 dark:text-red-300 mt-0.5" />
|
||||
<div className="ml-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sendNowMutation.error?.message || "Failed to send metrics"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Information Panel */}
|
||||
<div className="bg-secondary-50 dark:bg-secondary-800/50 border border-secondary-200 dark:border-secondary-700 rounded-lg p-6">
|
||||
<div className="flex">
|
||||
<Info className="h-5 w-5 text-secondary-500 dark:text-secondary-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="ml-3 text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<h4 className="font-medium mb-2">How it works:</h4>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
Metrics are sent automatically every 24 hours when enabled
|
||||
</li>
|
||||
<li>
|
||||
Only host count and version number are transmitted (no
|
||||
sensitive data)
|
||||
</li>
|
||||
<li>The anonymous UUID prevents duplicate counting</li>
|
||||
<li>You can regenerate your ID or opt-out at any time</li>
|
||||
<li>
|
||||
All collected data is displayed publicly on{" "}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
patchmon.net
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMetrics;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Agent as HttpAgent } from "node:http";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
@@ -14,6 +15,15 @@ export default defineConfig({
|
||||
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// Configure HTTP agent to support more concurrent connections
|
||||
// Fixes 1000ms timeout issue when using HTTP (not HTTPS) with multiple hosts
|
||||
agent: new HttpAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: 50, // Increase from default 6 to handle multiple hosts
|
||||
maxFreeSockets: 10,
|
||||
timeout: 60000,
|
||||
keepAliveMsecs: 1000,
|
||||
}),
|
||||
configure:
|
||||
process.env.VITE_ENABLE_LOGGING === "true"
|
||||
? (proxy, _options) => {
|
||||
|
||||
530
package-lock.json
generated
530
package-lock.json
generated
@@ -78,7 +78,8 @@
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.30.1"
|
||||
"react-router-dom": "^6.30.1",
|
||||
"trianglify": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.14",
|
||||
@@ -1279,6 +1280,38 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"make-dir": "^3.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"nopt": "^5.0.0",
|
||||
"npmlog": "^5.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"tar": "^6.1.11"
|
||||
},
|
||||
"bin": {
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
@@ -1980,6 +2013,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -1993,6 +2032,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -2038,6 +2089,26 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
@@ -2196,7 +2267,6 @@
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -2419,6 +2489,21 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
|
||||
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^1.0.0",
|
||||
"nan": "^2.17.0",
|
||||
"simple-get": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2486,6 +2571,21 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/chroma-js": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz",
|
||||
"integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==",
|
||||
"license": "(BSD-3-Clause AND Apache-2.0)"
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
@@ -2567,6 +2667,15 @@
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/color/node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
@@ -2618,7 +2727,6 @@
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
@@ -2666,6 +2774,12 @@
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -2834,6 +2948,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
|
||||
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
@@ -2851,6 +2977,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz",
|
||||
"integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -2860,6 +2992,12 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -2900,7 +3038,6 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -3486,6 +3623,42 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -3510,6 +3683,33 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"aproba": "^1.0.3 || ^2.0.0",
|
||||
"color-support": "^1.1.2",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wide-align": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -3693,6 +3893,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -3761,6 +3967,19 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -3780,6 +3999,17 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -4369,6 +4599,21 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4461,11 +4706,22 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
|
||||
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -4484,6 +4740,49 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
@@ -4542,6 +4841,12 @@
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -4576,6 +4881,26 @@
|
||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
@@ -4670,6 +4995,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "1"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -4690,6 +5030,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"are-we-there-yet": "^2.0.0",
|
||||
"console-control-strings": "^1.1.0",
|
||||
"gauge": "^3.0.0",
|
||||
"set-blocking": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||
@@ -4760,6 +5113,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/one-time": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||
@@ -4838,6 +5200,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -5543,6 +5914,43 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
@@ -5667,7 +6075,6 @@
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -5869,6 +6276,37 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
|
||||
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^4.2.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
@@ -6134,6 +6572,38 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
"minipass": "^5.0.0",
|
||||
"minizlib": "^2.1.1",
|
||||
"mkdirp": "^1.0.3",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
@@ -6249,6 +6719,12 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
@@ -6259,6 +6735,17 @@
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/trianglify": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/trianglify/-/trianglify-4.1.1.tgz",
|
||||
"integrity": "sha512-zWfv8Qq9b3eYYiMMseGd1kvOW4xqfyGXs5xSA3pgpM2ynP+ABSFK81yVtwk4Waj9tC9jwJLiR3DHLPKlzZYO5Q==",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"canvas": "^2.6.1",
|
||||
"chroma-js": "^2.1.0",
|
||||
"delaunator": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/triple-beam": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||
@@ -6499,6 +6986,22 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -6521,6 +7024,15 @@
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
},
|
||||
"node_modules/winston": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
|
||||
@@ -6594,6 +7106,12 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
|
||||
Reference in New Issue
Block a user