mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Implement admin dash workspaces tab
This commit is contained in:
@@ -50,6 +50,10 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddlew
|
||||
r.Put("/{userId}", handler.AdminUpdateUser())
|
||||
r.Delete("/{userId}", handler.AdminDeleteUser())
|
||||
})
|
||||
// Workspace management
|
||||
r.Route("/workspaces", func(r chi.Router) {
|
||||
r.Get("/", handler.AdminListWorkspaces())
|
||||
})
|
||||
// System stats
|
||||
r.Get("/stats", handler.AdminGetSystemStats())
|
||||
})
|
||||
|
||||
@@ -249,3 +249,43 @@ func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) {
|
||||
}
|
||||
return filePath.String, nil
|
||||
}
|
||||
|
||||
// GetAllWorkspaces retrieves all workspaces in the database
|
||||
func (db *DB) GetAllWorkspaces() ([]*models.Workspace, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var workspaces []*models.Workspace
|
||||
for rows.Next() {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
err := rows.Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
workspaces = append(workspaces, workspace)
|
||||
}
|
||||
return workspaces, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"novamd/internal/httpcontext"
|
||||
"novamd/internal/models"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -204,6 +205,58 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// WorkspaceStats holds workspace statistics
|
||||
type WorkspaceStats struct {
|
||||
UserID int `json:"userID"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
WorkspaceID int `json:"workspaceID"`
|
||||
WorkspaceName string `json:"workspaceName"`
|
||||
WorkspaceCreatedAt time.Time `json:"workspaceCreatedAt"`
|
||||
*filesystem.FileCountStats
|
||||
}
|
||||
|
||||
// AdminListWorkspaces returns a list of all workspaces and their stats
|
||||
func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
workspaces, err := h.DB.GetAllWorkspaces()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
workspacesStats := make([]*WorkspaceStats, 0, len(workspaces))
|
||||
|
||||
for _, ws := range workspaces {
|
||||
|
||||
workspaceData := &WorkspaceStats{}
|
||||
|
||||
user, err := h.DB.GetUserByID(ws.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceData.UserID = ws.UserID
|
||||
workspaceData.UserEmail = user.Email
|
||||
workspaceData.WorkspaceID = ws.ID
|
||||
workspaceData.WorkspaceName = ws.Name
|
||||
workspaceData.WorkspaceCreatedAt = ws.CreatedAt
|
||||
|
||||
fileStats, err := h.FS.GetFileStats(ws.UserID, ws.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get file stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceData.FileCountStats = fileStats
|
||||
|
||||
workspacesStats = append(workspacesStats, workspaceData)
|
||||
}
|
||||
|
||||
respondJSON(w, workspacesStats)
|
||||
}
|
||||
}
|
||||
|
||||
// SystemStats holds system-wide statistics
|
||||
type SystemStats struct {
|
||||
*db.UserStats
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Table, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useAdmin } from '../../../hooks/useAdmin';
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
import { useAdminData } from '../../../hooks/useAdminData';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
|
||||
const AdminStatsTab = () => {
|
||||
const { data: stats, loading, error } = useAdmin('stats');
|
||||
const { data: stats, loading, error } = useAdminData('stats');
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
|
||||
@@ -16,20 +16,20 @@ import {
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useAdmin } from '../../../hooks/useAdmin';
|
||||
import { useUserAdmin } from '../../../hooks/useUserAdmin';
|
||||
import CreateUserModal from '../../modals/user/CreateUserModal';
|
||||
import EditUserModal from '../../modals/user/EditUserModal';
|
||||
import DeleteUserModal from '../../modals/user/DeleteUserModal';
|
||||
|
||||
const AdminUsersTab = ({ currentUser }) => {
|
||||
const {
|
||||
data: users,
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
create,
|
||||
update,
|
||||
delete: deleteUser,
|
||||
} = useAdmin('users');
|
||||
} = useUserAdmin();
|
||||
|
||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||
const [editModalData, setEditModalData] = useState(null);
|
||||
|
||||
@@ -9,27 +9,21 @@ import {
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useAdmin } from '../../../hooks/useAdmin';
|
||||
import { useAdminData } from '../../../hooks/useAdminData';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
|
||||
const AdminWorkspacesTab = () => {
|
||||
const { data: workspaces, loading, error } = useAdmin('workspaces');
|
||||
const { data: workspaces, loading, error } = useAdminData('workspaces');
|
||||
|
||||
const rows = workspaces.map((workspace) => (
|
||||
<Table.Tr key={workspace.id}>
|
||||
<Table.Td>{workspace.name}</Table.Td>
|
||||
<Table.Td>{workspace.owner?.email}</Table.Td>
|
||||
<Table.Td>{new Date(workspace.createdAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>{workspace.gitEnabled ? 'Yes' : 'No'}</Table.Td>
|
||||
<Table.Td>{workspace.userEmail}</Table.Td>
|
||||
<Table.Td>{workspace.workspaceName}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ActionIcon variant="subtle" color="blue">
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red">
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
<Table.Td>{workspace.totalFiles}</Table.Td>
|
||||
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
@@ -57,11 +51,11 @@ const AdminWorkspacesTab = () => {
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Created At</Table.Th>
|
||||
<Table.Th>Git Enabled</Table.Th>
|
||||
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
||||
<Table.Th>Total Files</Table.Th>
|
||||
<Table.Th>Total Size</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import * as adminApi from '../services/adminApi';
|
||||
|
||||
export const useAdmin = (resource) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let result;
|
||||
switch (resource) {
|
||||
case 'users':
|
||||
result = await adminApi.listUsers();
|
||||
break;
|
||||
case 'stats':
|
||||
result = await adminApi.getSystemStats();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown resource type: ${resource}`);
|
||||
}
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch ${resource}:`, err);
|
||||
setError(err.message);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to load ${resource}. Please try again.`,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [resource]);
|
||||
|
||||
const createItem = useCallback(
|
||||
async (itemData) => {
|
||||
try {
|
||||
let newItem;
|
||||
switch (resource) {
|
||||
case 'users':
|
||||
newItem = await adminApi.createUser(itemData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Create not supported for resource: ${resource}`);
|
||||
}
|
||||
setData((prevData) =>
|
||||
Array.isArray(prevData) ? [...prevData, newItem] : prevData
|
||||
);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `${resource} created successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
return { success: true, data: newItem };
|
||||
} catch (err) {
|
||||
console.error(`Failed to create ${resource}:`, err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || `Failed to create ${resource}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
},
|
||||
[resource]
|
||||
);
|
||||
|
||||
const deleteItem = useCallback(
|
||||
async (itemId) => {
|
||||
try {
|
||||
switch (resource) {
|
||||
case 'users':
|
||||
await adminApi.deleteUser(itemId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Delete not supported for resource: ${resource}`);
|
||||
}
|
||||
setData((prevData) =>
|
||||
Array.isArray(prevData)
|
||||
? prevData.filter((item) => item.id !== itemId)
|
||||
: prevData
|
||||
);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `${resource} deleted successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete ${resource}:`, err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || `Failed to delete ${resource}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
},
|
||||
[resource]
|
||||
);
|
||||
|
||||
const updateItem = useCallback(
|
||||
async (itemId, itemData) => {
|
||||
try {
|
||||
let updatedItem;
|
||||
switch (resource) {
|
||||
case 'users':
|
||||
updatedItem = await adminApi.updateUser(itemId, itemData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Update not supported for resource: ${resource}`);
|
||||
}
|
||||
setData((prevData) =>
|
||||
Array.isArray(prevData)
|
||||
? prevData.map((item) => (item.id === itemId ? updatedItem : item))
|
||||
: prevData
|
||||
);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `${resource} updated successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
return { success: true, data: updatedItem };
|
||||
} catch (err) {
|
||||
console.error(`Failed to update ${resource}:`, err);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message || `Failed to update ${resource}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
},
|
||||
[resource]
|
||||
);
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchData,
|
||||
create: createItem,
|
||||
delete: deleteItem,
|
||||
update: updateItem,
|
||||
};
|
||||
};
|
||||
48
frontend/src/hooks/useAdminData.js
Normal file
48
frontend/src/hooks/useAdminData.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi';
|
||||
|
||||
// Hook for admin data fetching (stats and workspaces)
|
||||
export const useAdminData = (type) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let response;
|
||||
switch (type) {
|
||||
case 'stats':
|
||||
response = await getSystemStats();
|
||||
break;
|
||||
case 'workspaces':
|
||||
response = await getWorkspaces();
|
||||
break;
|
||||
case 'users':
|
||||
response = await getUsers();
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid data type');
|
||||
}
|
||||
setData(response);
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
setError(message);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to load ${type}: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [type]);
|
||||
|
||||
return { data, loading, error, reload: loadData };
|
||||
};
|
||||
79
frontend/src/hooks/useUserAdmin.js
Normal file
79
frontend/src/hooks/useUserAdmin.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useAdminData } from './useAdminData';
|
||||
import { createUser, updateUser, deleteUser } from '../services/adminApi';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
export const useUserAdmin = () => {
|
||||
const { data: users, loading, error, reload } = useAdminData('users');
|
||||
|
||||
const handleCreate = async (userData) => {
|
||||
try {
|
||||
await createUser(userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
reload();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (userId, userData) => {
|
||||
try {
|
||||
await updateUser(userId, userData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
reload();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to update user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId) => {
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
reload();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message;
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: `Failed to delete user: ${message}`,
|
||||
color: 'red',
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
create: handleCreate,
|
||||
update: handleUpdate,
|
||||
delete: handleDelete,
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { API_BASE_URL } from '../utils/constants';
|
||||
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
|
||||
|
||||
// User Management
|
||||
export const listUsers = async () => {
|
||||
export const getUsers = async () => {
|
||||
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
|
||||
return response.json();
|
||||
};
|
||||
@@ -36,6 +36,12 @@ export const updateUser = async (userId, userData) => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Workspace Management
|
||||
export const getWorkspaces = async () => {
|
||||
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// System Statistics
|
||||
export const getSystemStats = async () => {
|
||||
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
|
||||
|
||||
10
frontend/src/utils/formatBytes.js
Normal file
10
frontend/src/utils/formatBytes.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const formatBytes = (bytes) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
Reference in New Issue
Block a user