Initial commit
This commit is contained in:
commit
6e3d1c0d67
|
|
@ -0,0 +1,187 @@
|
||||||
|
# GlobalQuota - Nextcloud App v1.2.1
|
||||||
|
|
||||||
|
Define and monitor a global storage quota, compatible with S3 and external storage.
|
||||||
|
|
||||||
|
**NEW v1.2.1**: Enhanced UI reliability with fallback text display and improved debugging.
|
||||||
|
**NEW v1.2.0**: Adaptive visualization with ServerInfo integration or standalone Chart.js donut.
|
||||||
|
**NEW v1.1.0**: Blocks uploads when global quota is exceeded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* ✅ **Global quota definition and monitoring**
|
||||||
|
* ✅ **Compatible with S3 and external storage backends**
|
||||||
|
* ✅ **Upload blocking when quota exceeded** (PSR-14 events)
|
||||||
|
* ✅ **OCC commands** for management
|
||||||
|
* ✅ **REST API** for external integration
|
||||||
|
* ✅ **Server Info dashboard integration**
|
||||||
|
* ✅ **Adaptive visualization (v1.2.0)**:
|
||||||
|
+ Integrates seamlessly into ServerInfo if supported
|
||||||
|
+ Shows its own donut chart in Admin Settings otherwise
|
||||||
|
* ✅ **Enhanced reliability (v1.2.1)**:
|
||||||
|
+ Always displays quota values in text format
|
||||||
|
+ Robust error handling and debugging logs
|
||||||
|
+ Chart.js fallback mechanisms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin Settings Panel
|
||||||
|
The app displays quota information with both visual chart and text values:
|
||||||
|
- **Used storage**: Shows current usage
|
||||||
|
- **Free storage**: Available space remaining
|
||||||
|
- **Total storage**: Global quota limit
|
||||||
|
- **Usage percentage**: Visual indicator with color coding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Download** or clone this repository
|
||||||
|
2. **Copy** the `globalquota` folder to your Nextcloud `apps/` directory
|
||||||
|
3. **Enable** the app in Nextcloud Admin Settings → Apps
|
||||||
|
4. **Configure** the global quota in Admin Settings → Additional Settings → Global Quota
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to `config/config.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'globalquota' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'quota_bytes' => 500 * 1024 * 1024 * 1024, // 500 GB
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OCC Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set quota (in bytes)
|
||||||
|
occ globalquota:set 2147483648 # 2 GB
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
occ globalquota:status
|
||||||
|
|
||||||
|
# Force recalculation
|
||||||
|
occ globalquota:recalc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get current status (new endpoint for admin chart)
|
||||||
|
curl -H "Authorization: Bearer TOKEN" \
|
||||||
|
https://instance.com/apps/globalquota/status
|
||||||
|
|
||||||
|
# Legacy API v1 endpoint
|
||||||
|
curl -H "Authorization: Bearer TOKEN" \
|
||||||
|
https://instance.com/apps/globalquota/api/v1/status
|
||||||
|
|
||||||
|
# Update quota
|
||||||
|
curl -X PUT \
|
||||||
|
-H "Authorization: Bearer TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"quota_bytes": 10737418240}' \
|
||||||
|
https://instance.com/apps/globalquota/api/v1/quota
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upload Blocking
|
||||||
|
|
||||||
|
When the global quota is exceeded, the app will:
|
||||||
|
|
||||||
|
* ❌ **Block new file uploads**
|
||||||
|
* ❌ **Block file updates that increase size**
|
||||||
|
* 🚫 **Return HTTP 507 "Insufficient Storage" error**
|
||||||
|
* 📱 **Work with all clients** (web, desktop, mobile, WebDAV)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visualization Modes
|
||||||
|
|
||||||
|
### 🔗 **With ServerInfo Integration**
|
||||||
|
If ServerInfo supports events (`LoadAdditionalDataEvent`), GlobalQuota **replaces the Disk chart** with GlobalQuota data.
|
||||||
|
|
||||||
|
### 📊 **Standalone Mode**
|
||||||
|
If ServerInfo does not support events, GlobalQuota shows its **own donut chart** in the Admin Settings section.
|
||||||
|
|
||||||
|
### 📝 **Text Fallback (v1.2.1)**
|
||||||
|
Even if the chart fails to load, quota values are **always displayed in text format** for reliability.
|
||||||
|
|
||||||
|
**Both modes display:**
|
||||||
|
- Usage percentage with color coding
|
||||||
|
- Used / Free / Total values (formatted)
|
||||||
|
- Auto-refresh capability via "Refresh" button
|
||||||
|
- Detailed console logging for debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Chart not displaying?
|
||||||
|
1. Check browser console for errors (`F12` → Console)
|
||||||
|
2. Look for `GlobalQuota:` log messages
|
||||||
|
3. Verify Chart.js is loading (CDN or local)
|
||||||
|
4. Text values should still display regardless
|
||||||
|
|
||||||
|
### API errors?
|
||||||
|
1. Verify app is enabled and configured
|
||||||
|
2. Check Nextcloud logs for backend errors
|
||||||
|
3. Test endpoints directly with curl
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* **Nextcloud 25+**
|
||||||
|
* **Admin privileges** for configuration
|
||||||
|
* **Optional**: ServerInfo app (for dashboard integration)
|
||||||
|
* **Browser**: Modern browser with JavaScript enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
* **v1.2.1** *(Latest)*
|
||||||
|
+ Enhanced UI reliability with always-visible text values
|
||||||
|
+ Improved error handling and debugging logs
|
||||||
|
+ Chart.js fallback mechanisms
|
||||||
|
+ Better console logging for troubleshooting
|
||||||
|
|
||||||
|
* **v1.2.0**
|
||||||
|
+ Adaptive visualization (ServerInfo integration or standalone Chart.js donut)
|
||||||
|
+ New `/apps/globalquota/status` endpoint
|
||||||
|
+ Improved admin settings panel
|
||||||
|
|
||||||
|
* **v1.1.0**
|
||||||
|
+ Upload blocking functionality with PSR-14 events
|
||||||
|
+ OCC commands and REST API integration
|
||||||
|
|
||||||
|
* **v1.0.0**
|
||||||
|
+ Initial release with basic quota management and S3 compatibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. **Fork** this repository
|
||||||
|
2. **Create** a feature branch
|
||||||
|
3. **Make** your changes
|
||||||
|
4. **Test** thoroughly
|
||||||
|
5. **Submit** a pull request
|
||||||
|
|
||||||
|
### Reporting Issues
|
||||||
|
Please report bugs and feature requests on [GitHub Issues](https://github.com/DavidCamejo/globalquota/issues).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Author:** David Camejo
|
||||||
|
**License:** AGPL-3.0
|
||||||
|
**Repository:** https://github.com/DavidCamejo/globalquota
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Load application class
|
||||||
|
*/
|
||||||
|
namespace OCA\GlobalQuota\AppInfo;
|
||||||
|
|
||||||
|
$app = new Application();
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||||
|
<id>globalquota</id>
|
||||||
|
<name>Global Quota</name>
|
||||||
|
<summary>Manage global storage quota with adaptive visualization</summary>
|
||||||
|
<description>Global Quota allows administrators to set and monitor a global storage limit across all users. Integrates with ServerInfo if available, or shows its own chart.</description>
|
||||||
|
<version>1.2.0</version>
|
||||||
|
<licence>agpl</licence>
|
||||||
|
<author>David Camejo</author>
|
||||||
|
<namespace>GlobalQuota</namespace>
|
||||||
|
<category>tools</category>
|
||||||
|
<dependencies>
|
||||||
|
<nextcloud min-version="25" max-version="30"/>
|
||||||
|
</dependencies>
|
||||||
|
<commands>
|
||||||
|
<command>OCA\GlobalQuota\Command\StatusCommand</command>
|
||||||
|
<command>OCA\GlobalQuota\Command\SetQuotaCommand</command>
|
||||||
|
<command>OCA\GlobalQuota\Command\RecalcCommand</command>
|
||||||
|
</commands>
|
||||||
|
<settings>
|
||||||
|
<admin>OCA\GlobalQuota\Settings\Admin\Settings</admin>
|
||||||
|
<admin-section>OCA\GlobalQuota\Settings\Admin\Section</admin-section>
|
||||||
|
</settings>
|
||||||
|
</info>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'routes' => [
|
||||||
|
['name' => 'quota#status', 'url' => '/status', 'verb' => 'GET'],
|
||||||
|
['name' => 'quota#apiStatus', 'url' => '/api/v1/status', 'verb' => 'GET'],
|
||||||
|
['name' => 'quota#updateQuota', 'url' => '/api/v1/quota', 'verb' => 'PUT'],
|
||||||
|
['name' => 'quota#setQuota', 'url' => '/set', 'verb' => 'POST'],
|
||||||
|
['name' => 'quota#getQuota', 'url' => '/quota', 'verb' => 'GET'],
|
||||||
|
['name' => 'quota#recalc', 'url' => '/recalc', 'verb' => 'GET'],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#globalquota.section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-chart-wrapper {
|
||||||
|
width: 280px;
|
||||||
|
min-height: 260px;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-main-background);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-stats {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 260px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-main-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-stats h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-list li {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-error {
|
||||||
|
color: #b71c1c;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* GlobalQuota Admin Settings Styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
#globalquota-settings {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#globalquota-settings h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-chart-container {
|
||||||
|
background: var(--color-main-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-chart-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#globalquota-chart {
|
||||||
|
max-width: 280px;
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-stats {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--color-border-dark);
|
||||||
|
padding: 6px 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globalquota-actions .button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="black" stroke-width="4" fill="#0078d7"/>
|
||||||
|
<text x="32" y="37" font-size="20" text-anchor="middle" fill="white">GQ</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 231 B |
|
|
@ -0,0 +1,359 @@
|
||||||
|
/**
|
||||||
|
* GlobalQuota Admin Settings JavaScript
|
||||||
|
* Maneja el gráfico donut y las acciones de administración
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Validate Nextcloud environment
|
||||||
|
if (typeof OC === 'undefined' || typeof t === 'undefined') {
|
||||||
|
console.error('GlobalQuota: Nextcloud environment not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GlobalQuota admin JS loaded ✅');
|
||||||
|
|
||||||
|
let quotaChart = null;
|
||||||
|
let chartInitialized = false;
|
||||||
|
let dataLoaded = false;
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
const canvas = document.getElementById('globalquota-chart');
|
||||||
|
if (!canvas) {
|
||||||
|
console.warn('GlobalQuota: canvas #globalquota-chart no encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.warn('GlobalQuota: Chart.js no está disponible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
quotaChart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: [t('globalquota', 'Used'), t('globalquota', 'Free')],
|
||||||
|
datasets: [{
|
||||||
|
data: [0, 100],
|
||||||
|
backgroundColor: ['#e74c3c', '#ecf0f1'],
|
||||||
|
borderWidth: 0,
|
||||||
|
cutout: '70%'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = formatBytes(context.raw);
|
||||||
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const percentage = total > 0 ? ((context.raw / total) * 100).toFixed(1) : '0.0';
|
||||||
|
return `${label}: ${value} (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chartInitialized = true;
|
||||||
|
console.log('GlobalQuota: Gráfico inicializado correctamente');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GlobalQuota: Error al inicializar el gráfico:', error);
|
||||||
|
quotaChart = null;
|
||||||
|
chartInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadQuotaData() {
|
||||||
|
// Prevent multiple simultaneous requests
|
||||||
|
if (dataLoaded) {
|
||||||
|
console.log('GlobalQuota: Data already loading, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataLoaded = true;
|
||||||
|
console.log('GlobalQuota: Cargando datos...');
|
||||||
|
|
||||||
|
// Clear any previous errors
|
||||||
|
const errorEl = document.getElementById('quota-error');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
errorEl.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the new /status endpoint first
|
||||||
|
fetch(OC.generateUrl('/apps/globalquota/status'))
|
||||||
|
.then(async response => {
|
||||||
|
console.log('GlobalQuota: Respuesta del endpoint /status:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Empty response');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
console.log('GlobalQuota: Datos recibidos de /status:', data);
|
||||||
|
return data;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('GlobalQuota: respuesta no-JSON', text);
|
||||||
|
throw new Error('Invalid JSON response');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data && typeof data.used !== 'undefined' && typeof data.total !== 'undefined') {
|
||||||
|
processQuotaData(data);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid data structure');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log('GlobalQuota: /status failed, trying /quota endpoint...', error.message);
|
||||||
|
|
||||||
|
// Fallback to legacy /quota endpoint
|
||||||
|
return fetch(OC.generateUrl('/apps/globalquota/quota'))
|
||||||
|
.then(response => {
|
||||||
|
console.log('GlobalQuota: Respuesta del endpoint /quota:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
console.log('GlobalQuota: Datos recibidos de /quota:', data);
|
||||||
|
|
||||||
|
if (data && typeof data.used !== 'undefined' && typeof data.total !== 'undefined') {
|
||||||
|
processQuotaData(data);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid quota data structure');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(fallbackError => {
|
||||||
|
console.error('GlobalQuota: Both endpoints failed:', fallbackError);
|
||||||
|
showError('Failed to load quota data from both endpoints');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dataLoaded = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function processQuotaData(data) {
|
||||||
|
// Unified calculation logic for both chart and stats
|
||||||
|
const used = Number(data.used) || 0;
|
||||||
|
const total = Number(data.total) || 0;
|
||||||
|
const available = Number(data.available || data.free || 0);
|
||||||
|
|
||||||
|
// Calculate free space with unified logic
|
||||||
|
let free;
|
||||||
|
if (total > 0) {
|
||||||
|
// If we have total, calculate free as total - used
|
||||||
|
free = Math.max(0, total - used);
|
||||||
|
} else if (available > 0) {
|
||||||
|
// If no total but we have available, use that
|
||||||
|
free = available;
|
||||||
|
} else {
|
||||||
|
// Fallback: assume no free space if we can't calculate
|
||||||
|
free = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate percentage
|
||||||
|
const percentage = total > 0 ? ((used / total) * 100) : 0;
|
||||||
|
|
||||||
|
// Create normalized data object
|
||||||
|
const normalizedData = {
|
||||||
|
used: used,
|
||||||
|
free: free,
|
||||||
|
total: total > 0 ? total : (used + free),
|
||||||
|
percentage: percentage,
|
||||||
|
formatted: data.formatted || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('GlobalQuota: Normalized data:', normalizedData);
|
||||||
|
|
||||||
|
// Update both chart and stats with the same data
|
||||||
|
updateChart(normalizedData);
|
||||||
|
updateStats(normalizedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(data) {
|
||||||
|
if (!quotaChart || !chartInitialized) {
|
||||||
|
console.warn('GlobalQuota: Chart not initialized, skipping update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GlobalQuota: Updating chart with:', {
|
||||||
|
used: data.used,
|
||||||
|
free: data.free,
|
||||||
|
total: data.total
|
||||||
|
});
|
||||||
|
|
||||||
|
quotaChart.data.datasets[0].data = [data.used, data.free];
|
||||||
|
|
||||||
|
// Update colors based on usage percentage
|
||||||
|
let usedColor = '#2ecc71'; // Green
|
||||||
|
|
||||||
|
if (data.percentage > 90) {
|
||||||
|
usedColor = '#e74c3c'; // Red
|
||||||
|
} else if (data.percentage > 75) {
|
||||||
|
usedColor = '#f39c12'; // Orange
|
||||||
|
} else if (data.percentage > 50) {
|
||||||
|
usedColor = '#f1c40f'; // Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaChart.data.datasets[0].backgroundColor = [usedColor, '#ecf0f1'];
|
||||||
|
quotaChart.update('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(data) {
|
||||||
|
// Use the same normalized data for stats
|
||||||
|
const elems = {
|
||||||
|
'quota-used': data.formatted?.used || formatBytes(data.used),
|
||||||
|
'quota-free': data.formatted?.free || data.formatted?.available || formatBytes(data.free),
|
||||||
|
'quota-total': data.formatted?.total || formatBytes(data.total),
|
||||||
|
'quota-percentage': data.percentage.toFixed(1) + '%'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('GlobalQuota: Updating stats with:', elems);
|
||||||
|
|
||||||
|
Object.entries(elems).forEach(([id, val]) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.textContent = val;
|
||||||
|
} else {
|
||||||
|
console.warn(`GlobalQuota: Element #${id} not found`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || isNaN(bytes) || bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
const idx = Math.max(0, Math.min(i, sizes.length - 1));
|
||||||
|
const num = bytes / Math.pow(k, idx);
|
||||||
|
|
||||||
|
return num.toFixed(2) + ' ' + sizes[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
console.error('GlobalQuota:', msg);
|
||||||
|
|
||||||
|
// Try to show error in dedicated error element
|
||||||
|
const errorEl = document.getElementById('quota-error');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try Nextcloud notification system
|
||||||
|
try {
|
||||||
|
if (OC && OC.Notification) {
|
||||||
|
OC.Notification.showTemporary(msg, { type: 'error' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('GlobalQuota: Could not show notification:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChartJS() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof Chart !== 'undefined') {
|
||||||
|
console.log('GlobalQuota: Chart.js already available');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GlobalQuota: Loading Chart.js...');
|
||||||
|
|
||||||
|
// Try to load from CDN first
|
||||||
|
const cdnScript = document.createElement('script');
|
||||||
|
cdnScript.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js';
|
||||||
|
cdnScript.onload = function() {
|
||||||
|
console.log('GlobalQuota: Chart.js loaded from CDN');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
cdnScript.onerror = function() {
|
||||||
|
console.warn('GlobalQuota: CDN failed, trying local Chart.js...');
|
||||||
|
|
||||||
|
// Fallback to local file
|
||||||
|
const localScript = document.createElement('script');
|
||||||
|
localScript.src = OC.filePath('globalquota', 'js', 'chart.min.js');
|
||||||
|
localScript.onload = function() {
|
||||||
|
console.log('GlobalQuota: Chart.js loaded locally');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
localScript.onerror = function() {
|
||||||
|
console.error('GlobalQuota: Failed to load Chart.js from both CDN and local');
|
||||||
|
reject(new Error('Chart.js not available'));
|
||||||
|
};
|
||||||
|
document.head.appendChild(localScript);
|
||||||
|
};
|
||||||
|
document.head.appendChild(cdnScript);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('GlobalQuota: DOM loaded, iniciando...');
|
||||||
|
|
||||||
|
// Initialize chart if canvas exists
|
||||||
|
const canvas = document.getElementById('globalquota-chart');
|
||||||
|
if (canvas) {
|
||||||
|
console.log('GlobalQuota: Canvas found, loading Chart.js...');
|
||||||
|
|
||||||
|
loadChartJS()
|
||||||
|
.then(() => {
|
||||||
|
initChart();
|
||||||
|
// Load data only after chart is ready
|
||||||
|
loadQuotaData();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('GlobalQuota: Failed to initialize chart:', error);
|
||||||
|
showError('Chart visualization not available');
|
||||||
|
// Still load data for stats even if chart fails
|
||||||
|
loadQuotaData();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('GlobalQuota: Canvas #globalquota-chart not found');
|
||||||
|
// Load data for stats only
|
||||||
|
loadQuotaData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up refresh button
|
||||||
|
const refreshBtn = document.getElementById('refresh-quota');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', function() {
|
||||||
|
console.log('GlobalQuota: Refresh button clicked');
|
||||||
|
loadQuotaData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\AppInfo;
|
||||||
|
|
||||||
|
use OCP\AppFramework\App;
|
||||||
|
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||||
|
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||||
|
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||||
|
use OCP\EventDispatcher\IEventDispatcher;
|
||||||
|
use OCA\GlobalQuota\Listener\BeforeFileWrittenListener;
|
||||||
|
use OCA\GlobalQuota\Listener\OverrideServerInfoListener;
|
||||||
|
use OCP\Files\Events\Node\BeforeNodeWrittenEvent;
|
||||||
|
|
||||||
|
class Application extends App implements IBootstrap {
|
||||||
|
public const APP_ID = 'globalquota';
|
||||||
|
|
||||||
|
public function __construct(array $urlParams = []) {
|
||||||
|
parent::__construct(self::APP_ID, $urlParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(IRegistrationContext $context): void {
|
||||||
|
// Registro del listener para bloqueo de uploads
|
||||||
|
$context->registerEventListener(
|
||||||
|
BeforeNodeWrittenEvent::class,
|
||||||
|
BeforeFileWrittenListener::class
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🚀 DETECCIÓN AUTOMÁTICA: ServerInfo vs Panel Propio
|
||||||
|
if (class_exists('\OCA\ServerInfo\Events\LoadAdditionalDataEvent')) {
|
||||||
|
// Caso 1: ServerInfo soporta eventos → nos integramos
|
||||||
|
$context->registerEventListener(
|
||||||
|
\OCA\ServerInfo\Events\LoadAdditionalDataEvent::class,
|
||||||
|
OverrideServerInfoListener::class
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Caso 2: ServerInfo no soporta eventos → panel propio en Admin Settings
|
||||||
|
$context->registerService('GlobalQuotaAdminSettings', function() {
|
||||||
|
return new \OCA\GlobalQuota\Settings\Admin\Settings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(IBootContext $context): void {
|
||||||
|
// Boot logic if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Command;
|
||||||
|
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class RecalcCommand extends Command {
|
||||||
|
private $quotaService;
|
||||||
|
public function __construct(QuotaService $quotaService) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
protected function configure() {
|
||||||
|
$this->setName('globalquota:recalc')
|
||||||
|
->setDescription('Recalculate global usage');
|
||||||
|
}
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output) {
|
||||||
|
$status = $this->quotaService->getStatus(true);
|
||||||
|
$output->writeln("Recalculated usage: {$status['used_bytes']} bytes");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Command;
|
||||||
|
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class SetQuotaCommand extends Command {
|
||||||
|
private $quotaService;
|
||||||
|
public function __construct(QuotaService $quotaService) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
protected function configure() {
|
||||||
|
$this->setName('globalquota:set')
|
||||||
|
->setDescription('Set global quota in bytes')
|
||||||
|
->addArgument('bytes', InputArgument::REQUIRED, 'Quota in bytes');
|
||||||
|
}
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output) {
|
||||||
|
$bytes = (int)$input->getArgument('bytes');
|
||||||
|
$this->quotaService->setQuota($bytes);
|
||||||
|
$output->writeln("Global quota set to $bytes bytes");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Command;
|
||||||
|
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class StatusCommand extends Command {
|
||||||
|
private $quotaService;
|
||||||
|
public function __construct(QuotaService $quotaService) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
protected function configure() {
|
||||||
|
$this->setName('globalquota:status')
|
||||||
|
->setDescription('Show global quota and usage');
|
||||||
|
}
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output) {
|
||||||
|
$status = $this->quotaService->getStatus();
|
||||||
|
$output->writeln("Quota: {$status['quota_bytes']} bytes");
|
||||||
|
$output->writeln("Used: {$status['used_bytes']} bytes");
|
||||||
|
$output->writeln("Free: {$status['free_bytes']} bytes");
|
||||||
|
$output->writeln("Usage: {$status['usage_percentage']}%");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Controller;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Controller;
|
||||||
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
use OCP\IRequest;
|
||||||
|
|
||||||
|
class ApiController extends Controller {
|
||||||
|
private $quotaService;
|
||||||
|
|
||||||
|
public function __construct($AppName, IRequest $request, QuotaService $quotaService) {
|
||||||
|
parent::__construct($AppName, $request);
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoCSRFRequired
|
||||||
|
* @AdminRequired
|
||||||
|
*/
|
||||||
|
public function status(): DataResponse {
|
||||||
|
return new DataResponse($this->quotaService->getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoCSRFRequired
|
||||||
|
* @AdminRequired
|
||||||
|
*/
|
||||||
|
public function setQuota(): DataResponse {
|
||||||
|
$data = $this->request->getParams();
|
||||||
|
if (!isset($data['quota_bytes'])) {
|
||||||
|
return new DataResponse(['error' => 'Missing quota_bytes'], 400);
|
||||||
|
}
|
||||||
|
$this->quotaService->setQuota((int)$data['quota_bytes']);
|
||||||
|
return new DataResponse(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Controller;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Controller;
|
||||||
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
|
use OCP\IRequest;
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
|
||||||
|
class QuotaController extends Controller {
|
||||||
|
private QuotaService $quotaService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $appName,
|
||||||
|
IRequest $request,
|
||||||
|
QuotaService $quotaService
|
||||||
|
) {
|
||||||
|
parent::__construct($appName, $request);
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @NoAdminRequired @NoCSRFRequired */
|
||||||
|
public function status(): JSONResponse {
|
||||||
|
try {
|
||||||
|
$status = $this->quotaService->getStatus();
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'used' => $status['used_bytes'],
|
||||||
|
'total' => $status['quota_bytes'],
|
||||||
|
'free' => $status['free_bytes'],
|
||||||
|
'percentage' => $status['usage_percentage'],
|
||||||
|
'formatted' => [
|
||||||
|
'used' => $this->formatBytes($status['used_bytes']),
|
||||||
|
'total' => $this->formatBytes($status['quota_bytes']),
|
||||||
|
'free' => $this->formatBytes($status['free_bytes'])
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new JSONResponse(['success' => false, 'error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @NoAdminRequired @NoCSRFRequired */
|
||||||
|
public function apiStatus(): JSONResponse {
|
||||||
|
return $this->status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
* @NoCSRFRequired
|
||||||
|
*
|
||||||
|
* Endpoint for frontend quota display - returns simplified format
|
||||||
|
*/
|
||||||
|
public function getQuota(): JSONResponse {
|
||||||
|
try {
|
||||||
|
$status = $this->quotaService->getStatus();
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'used' => $status['used_bytes'],
|
||||||
|
'available' => $status['free_bytes'],
|
||||||
|
'total' => $status['quota_bytes'],
|
||||||
|
'percentage' => $status['usage_percentage'],
|
||||||
|
'formatted' => [
|
||||||
|
'used' => $this->formatBytes($status['used_bytes']),
|
||||||
|
'available' => $this->formatBytes($status['free_bytes']),
|
||||||
|
'total' => $this->formatBytes($status['quota_bytes'])
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @AdminRequired
|
||||||
|
* @NoCSRFRequired
|
||||||
|
*
|
||||||
|
* Recalculate quota usage
|
||||||
|
*/
|
||||||
|
public function recalc(): JSONResponse {
|
||||||
|
try {
|
||||||
|
// Force recalculation of quota usage
|
||||||
|
$status = $this->quotaService->getStatus(true);
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Quota recalculated successfully',
|
||||||
|
'data' => [
|
||||||
|
'used' => $status['used_bytes'],
|
||||||
|
'total' => $status['quota_bytes'],
|
||||||
|
'free' => $status['free_bytes'],
|
||||||
|
'percentage' => $status['usage_percentage'],
|
||||||
|
'formatted' => [
|
||||||
|
'used' => $this->formatBytes($status['used_bytes']),
|
||||||
|
'total' => $this->formatBytes($status['quota_bytes']),
|
||||||
|
'free' => $this->formatBytes($status['free_bytes'])
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Failed to recalculate quota: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @AdminRequired @NoCSRFRequired */
|
||||||
|
public function updateQuota(): JSONResponse {
|
||||||
|
$quotaBytes = $this->request->getParam('quota_bytes');
|
||||||
|
if (!is_numeric($quotaBytes) || $quotaBytes < 0) {
|
||||||
|
return new JSONResponse(['success' => false, 'error' => 'Invalid quota value'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->quotaService->setQuota((int)$quotaBytes);
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Quota updated successfully',
|
||||||
|
'data' => $this->quotaService->getStatus()
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new JSONResponse(['success' => false, 'error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @AdminRequired
|
||||||
|
* @NoCSRFRequired
|
||||||
|
*
|
||||||
|
* Set quota endpoint for REST API compatibility
|
||||||
|
* Accepts both 'bytes' and 'quota_bytes' parameters
|
||||||
|
*/
|
||||||
|
public function setQuota(): JSONResponse {
|
||||||
|
// Accept both 'bytes' and 'quota_bytes' for compatibility
|
||||||
|
$quotaBytes = $this->request->getParam('bytes') ?? $this->request->getParam('quota_bytes');
|
||||||
|
|
||||||
|
if (!is_numeric($quotaBytes) || $quotaBytes < 0) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid quota value. Please provide a valid number in bytes parameter.'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->quotaService->setQuota((int)$quotaBytes);
|
||||||
|
$status = $this->quotaService->getStatus();
|
||||||
|
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Global quota set successfully',
|
||||||
|
'data' => [
|
||||||
|
'quota_set' => (int)$quotaBytes,
|
||||||
|
'quota_formatted' => $this->formatBytes((int)$quotaBytes),
|
||||||
|
'current_status' => [
|
||||||
|
'used' => $status['used_bytes'],
|
||||||
|
'total' => $status['quota_bytes'],
|
||||||
|
'free' => $status['free_bytes'],
|
||||||
|
'percentage' => $status['usage_percentage'],
|
||||||
|
'formatted' => [
|
||||||
|
'used' => $this->formatBytes($status['used_bytes']),
|
||||||
|
'total' => $this->formatBytes($status['quota_bytes']),
|
||||||
|
'free' => $this->formatBytes($status['free_bytes'])
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new JSONResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Failed to set quota: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatBytes(int $bytes): string {
|
||||||
|
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||||
|
$bytes = max($bytes, 0);
|
||||||
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
|
$pow = min($pow, count($units) - 1);
|
||||||
|
$bytes /= (1 << (10 * $pow));
|
||||||
|
return round($bytes, 2) . ' ' . $units[$pow];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Listener;
|
||||||
|
|
||||||
|
use OCP\EventDispatcher\IEventListener;
|
||||||
|
use OCP\Files\Events\Node\BeforeFileCreatedEvent;
|
||||||
|
use OCP\Files\Events\Node\BeforeFileUpdatedEvent;
|
||||||
|
use OCP\Files\ForbiddenException;
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
|
||||||
|
class GlobalQuotaListener implements IEventListener {
|
||||||
|
private $quotaService;
|
||||||
|
|
||||||
|
public function __construct(QuotaService $quotaService) {
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maneja eventos de creación/actualización de archivos
|
||||||
|
* Bloquea uploads si exceden la cuota global
|
||||||
|
*/
|
||||||
|
public function handle($event): void {
|
||||||
|
if ($event instanceof BeforeFileCreatedEvent || $event instanceof BeforeFileUpdatedEvent) {
|
||||||
|
$quota = $this->quotaService->getQuota();
|
||||||
|
|
||||||
|
// Si no hay cuota global definida → no bloquea
|
||||||
|
if ($quota === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamaño actual del sistema
|
||||||
|
$used = $this->quotaService->getUsage();
|
||||||
|
|
||||||
|
// Tamaño esperado del nuevo archivo
|
||||||
|
$newSize = 0;
|
||||||
|
if (method_exists($event, 'getSize')) {
|
||||||
|
$newSize = $event->getSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el archivo ya existía (update), restar su tamaño actual
|
||||||
|
$currentSize = 0;
|
||||||
|
if ($event instanceof BeforeFileUpdatedEvent) {
|
||||||
|
try {
|
||||||
|
$node = $event->getNode();
|
||||||
|
if ($node && method_exists($node, 'getSize')) {
|
||||||
|
$currentSize = $node->getSize();
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$currentSize = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diferencia neta que sumará al uso total
|
||||||
|
$delta = max(0, $newSize - $currentSize);
|
||||||
|
|
||||||
|
// Verificar si excede la cuota
|
||||||
|
if (($used + $delta) > $quota) {
|
||||||
|
throw new ForbiddenException("Global quota exceeded. Upload blocked.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Listener;
|
||||||
|
|
||||||
|
use OCP\EventDispatcher\Event;
|
||||||
|
use OCP\EventDispatcher\IEventListener;
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
use OCA\ServerInfo\Events\LoadAdditionalDataEvent;
|
||||||
|
|
||||||
|
class OverrideServerInfoListener implements IEventListener {
|
||||||
|
private QuotaService $quotaService;
|
||||||
|
|
||||||
|
public function __construct(QuotaService $quotaService) {
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Event $event): void {
|
||||||
|
if (!($event instanceof LoadAdditionalDataEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$status = $this->quotaService->getStatus();
|
||||||
|
|
||||||
|
$event->addData('disk', [
|
||||||
|
'used' => $status['used_bytes'],
|
||||||
|
'available' => $status['free_bytes'],
|
||||||
|
'total' => $status['quota_bytes'],
|
||||||
|
'percent' => $status['percentage_used'],
|
||||||
|
'mount' => 'GlobalQuota',
|
||||||
|
'filesystem' => 'Global Storage'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->addData('quota_used', $status['used_bytes']);
|
||||||
|
$event->addData('quota_total', $status['quota_bytes']);
|
||||||
|
$event->addData('quota_free', $status['free_bytes']);
|
||||||
|
$event->addData('quota_percentage', $status['percentage_used']);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\OC::$server->getLogger()->error(
|
||||||
|
'GlobalQuota: Error al obtener datos para ServerInfo: ' . $e->getMessage(),
|
||||||
|
['app' => 'globalquota']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Service;
|
||||||
|
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IUserManager;
|
||||||
|
use OCP\Files\IRootFolder;
|
||||||
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
|
|
||||||
|
class QuotaService {
|
||||||
|
private $config;
|
||||||
|
private $userManager;
|
||||||
|
private $rootFolder;
|
||||||
|
private $timeFactory;
|
||||||
|
private $cacheKey = 'globalquota_usage';
|
||||||
|
private $cacheTTL = 300; // 5 minutos
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
IConfig $config,
|
||||||
|
IUserManager $userManager,
|
||||||
|
IRootFolder $rootFolder,
|
||||||
|
ITimeFactory $timeFactory
|
||||||
|
) {
|
||||||
|
$this->config = $config;
|
||||||
|
$this->userManager = $userManager;
|
||||||
|
$this->rootFolder = $rootFolder;
|
||||||
|
$this->timeFactory = $timeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQuota(): ?int {
|
||||||
|
$val = $this->config->getSystemValue('globalquota', []);
|
||||||
|
return $val['quota_bytes'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setQuota(int $bytes): void {
|
||||||
|
$this->config->setSystemValue('globalquota', [
|
||||||
|
'quota_bytes' => $bytes
|
||||||
|
]);
|
||||||
|
$this->config->deleteAppValue('globalquota', $this->cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsage(bool $forceRecalc = false): int {
|
||||||
|
if (!$forceRecalc) {
|
||||||
|
$cached = $this->getCache();
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached['used'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalUsed = 0;
|
||||||
|
foreach ($this->userManager->search('') as $user) {
|
||||||
|
try {
|
||||||
|
$folder = $this->rootFolder->getUserFolder($user->getUID());
|
||||||
|
// Usar getSize() en lugar de getCache()->getUsedSpace()
|
||||||
|
$totalUsed += $folder->getSize();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Skip user if there's an error accessing their folder
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setCache($totalUsed);
|
||||||
|
return $totalUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(bool $forceRecalc = false): array {
|
||||||
|
$quota = $this->getQuota();
|
||||||
|
$used = $this->getUsage($forceRecalc);
|
||||||
|
return [
|
||||||
|
'quota_bytes' => $quota,
|
||||||
|
'used_bytes' => $used,
|
||||||
|
'free_bytes' => $quota - $used,
|
||||||
|
'usage_percentage' => $quota > 0 ? round(($used / $quota) * 100, 2) : 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recalculateUsage(): array {
|
||||||
|
// Fuerza el recálculo (sin cache)
|
||||||
|
$status = $this->getStatus(true);
|
||||||
|
|
||||||
|
// Borra el cache anterior y guarda el nuevo
|
||||||
|
$this->config->deleteAppValue('globalquota', $this->cacheKey);
|
||||||
|
$this->setCache($status['used_bytes']);
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCache(): ?array {
|
||||||
|
$raw = $this->config->getAppValue('globalquota', $this->cacheKey, null);
|
||||||
|
if ($raw === null) return null;
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (($this->timeFactory->getTime() - $data['timestamp']) > $this->cacheTTL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setCache(int $used): void {
|
||||||
|
$data = [
|
||||||
|
'used' => $used,
|
||||||
|
'timestamp' => $this->timeFactory->getTime()
|
||||||
|
];
|
||||||
|
$this->config->setAppValue('globalquota', $this->cacheKey, json_encode($data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Settings;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\Settings\ISettings;
|
||||||
|
use OCP\Util;
|
||||||
|
|
||||||
|
class Admin implements ISettings {
|
||||||
|
|
||||||
|
/** @var IL10N */
|
||||||
|
private $l10n;
|
||||||
|
/** @var IConfig */
|
||||||
|
private $config;
|
||||||
|
|
||||||
|
public function __construct(IL10N $l10n, IConfig $config) {
|
||||||
|
$this->l10n = $l10n;
|
||||||
|
$this->config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForm(): TemplateResponse {
|
||||||
|
Util::addScript('globalquota', 'admin-globalquota');
|
||||||
|
Util::addStyle('globalquota', 'admin-globalquota');
|
||||||
|
|
||||||
|
return new TemplateResponse('globalquota', 'admin', [
|
||||||
|
// aquí puedes pasar valores iniciales si hiciera falta
|
||||||
|
], 'blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSection(): string {
|
||||||
|
return 'server';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Settings\Admin;
|
||||||
|
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\Settings\IIconSection;
|
||||||
|
|
||||||
|
class Section implements IIconSection {
|
||||||
|
private IL10N $l;
|
||||||
|
private IURLGenerator $urlGenerator;
|
||||||
|
|
||||||
|
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
|
||||||
|
$this->l = $l;
|
||||||
|
$this->urlGenerator = $urlGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getID(): string {
|
||||||
|
return 'globalquota';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string {
|
||||||
|
return $this->l->t('Global Quota');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int {
|
||||||
|
return 75;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIcon(): string {
|
||||||
|
return $this->urlGenerator->imagePath('globalquota', 'app-dark.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\Settings\Admin;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\Settings\ISettings;
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
|
||||||
|
class Settings implements ISettings {
|
||||||
|
private IConfig $config;
|
||||||
|
private IL10N $l;
|
||||||
|
private QuotaService $quotaService;
|
||||||
|
|
||||||
|
public function __construct(IConfig $config, IL10N $l, QuotaService $quotaService) {
|
||||||
|
$this->config = $config;
|
||||||
|
$this->l = $l;
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForm(): TemplateResponse {
|
||||||
|
$showOwnChart = !class_exists('\OCA\ServerInfo\Events\LoadAdditionalDataEvent');
|
||||||
|
try {
|
||||||
|
$status = $this->quotaService->getStatus();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$status = ['used_bytes' => 0,'quota_bytes' => 0,'free_bytes' => 0,'percentage_used' => 0];
|
||||||
|
}
|
||||||
|
return new TemplateResponse('globalquota','admin-settings',[
|
||||||
|
'showChart' => $showOwnChart,
|
||||||
|
'quotaStatus' => $status,
|
||||||
|
'serverInfoIntegration' => !$showOwnChart
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSection(): string {
|
||||||
|
return 'globalquota';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\GlobalQuota\SystemInfo;
|
||||||
|
|
||||||
|
use OCA\GlobalQuota\Service\QuotaService;
|
||||||
|
use OCP\System\ISystemInfo;
|
||||||
|
|
||||||
|
class GlobalQuotaSystemInfo implements ISystemInfo {
|
||||||
|
private $quotaService;
|
||||||
|
|
||||||
|
public function __construct(QuotaService $quotaService) {
|
||||||
|
$this->quotaService = $quotaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDiskInfo(): array {
|
||||||
|
return $this->quotaService->getStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
script('globalquota', 'admin-settings');
|
||||||
|
style('globalquota', 'admin-settings');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div id="globalquota-settings" class="section">
|
||||||
|
<h2 class="inlineblock"><?php p($l->t('Global Quota')); ?></h2>
|
||||||
|
|
||||||
|
<?php if ($_['serverInfoIntegration']): ?>
|
||||||
|
<div class="globalquota-info">
|
||||||
|
<p><?php p($l->t('GlobalQuota is integrated with Server Info.')); ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="globalquota-chart-container">
|
||||||
|
<h3><?php p($l->t('Storage Usage')); ?></h3>
|
||||||
|
|
||||||
|
<!-- Error display element -->
|
||||||
|
<div id="quota-error" style="display: none; color: #e74c3c; background: #fdf2f2; padding: 10px; border-radius: 4px; margin: 10px 0; border: 1px solid #fecaca;"></div>
|
||||||
|
|
||||||
|
<div class="globalquota-chart-wrapper">
|
||||||
|
<canvas id="globalquota-chart" width="300" height="300"></canvas>
|
||||||
|
<div class="globalquota-stats">
|
||||||
|
<div class="stat-item"><span><?php p($l->t('Used:')); ?></span><span id="quota-used">-</span></div>
|
||||||
|
<div class="stat-item"><span><?php p($l->t('Free:')); ?></span><span id="quota-free">-</span></div>
|
||||||
|
<div class="stat-item"><span><?php p($l->t('Total:')); ?></span><span id="quota-total">-</span></div>
|
||||||
|
<div class="stat-item"><span><?php p($l->t('Usage:')); ?></span><span id="quota-percentage">-</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="globalquota-actions">
|
||||||
|
<button id="refresh-quota" class="button"><?php p($l->t('Refresh')); ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
script('globalquota', 'admin-globalquota'); // registra js/admin-globalquota.js
|
||||||
|
style('globalquota', 'admin-globalquota'); // registra css/admin-globalquota.css
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div id="globalquota" class="section">
|
||||||
|
<h2><?php p($l->t('Global Quota')); ?></h2>
|
||||||
|
|
||||||
|
<div class="globalquota-container">
|
||||||
|
<div class="globalquota-chart-wrapper">
|
||||||
|
<canvas id="globalquota-chart" width="240" height="240" aria-label="<?php p($l->t('Global quota donut chart')); ?>"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="globalquota-stats">
|
||||||
|
<h3><?php p($l->t('Storage Usage')); ?></h3>
|
||||||
|
<ul class="globalquota-list">
|
||||||
|
<li>
|
||||||
|
<strong><?php p($l->t('Used')); ?>:</strong>
|
||||||
|
<span id="quota-used">-</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><?php p($l->t('Free')); ?>:</strong>
|
||||||
|
<span id="quota-free">-</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><?php p($l->t('Total')); ?>:</strong>
|
||||||
|
<span id="quota-total">-</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong><?php p($l->t('Usage')); ?>:</strong>
|
||||||
|
<span id="quota-percentage">-</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button id="refresh-quota" class="button"><?php p($l->t('Refresh')); ?></button>
|
||||||
|
<div id="quota-error" class="globalquota-error" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cargar Chart.js por CDN para asegurar disponibilidad -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
Loading…
Reference in New Issue