Fix file sharing load speed and move error; misc updates
- Remove recursive directory size calculations (single Seafile API call per list) - Remove 'Used in this location' usage display - Fix move using v2 per-type endpoints instead of broken batch endpoint - Send entry type from frontend for correct move routing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const STATUS_ENDPOINTS = {
|
||||
supabase: 'https://supabase.statuspage.io/api/v2/summary.json',
|
||||
vercel: 'https://www.vercel-status.com/api/v2/summary.json',
|
||||
};
|
||||
|
||||
const SUPABASE_LIMITS = {
|
||||
storageBytes: 1024 * 1024 * 1024,
|
||||
databaseBytes: 500 * 1024 * 1024,
|
||||
egressBytes: 5 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const VERCEL_LIMITS = {
|
||||
fastDataTransferBytes: 100 * 1024 * 1024 * 1024,
|
||||
edgeRequests: 1_000_000,
|
||||
functionInvocations: 1_000_000,
|
||||
activeCpuHours: 4,
|
||||
};
|
||||
|
||||
function json(res, status, body) {
|
||||
res.status(status).setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function makeQuota(id, label, used, limit, unit, note, source = 'live') {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
used,
|
||||
limit,
|
||||
unit,
|
||||
note,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'FourgePortal/1.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchStatusSummary(url) {
|
||||
try {
|
||||
const summary = await fetchJson(url);
|
||||
return {
|
||||
ok: true,
|
||||
status: summary.status || null,
|
||||
updatedAt: summary.page?.updated_at || null,
|
||||
components: Array.isArray(summary.components)
|
||||
? summary.components.map(component => ({
|
||||
id: component.id,
|
||||
name: component.name,
|
||||
status: component.status,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: { indicator: 'major', description: error.message || 'Status unavailable' },
|
||||
updatedAt: null,
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function requireTeam(authHeader) {
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Supabase auth env is not configured on Vercel.');
|
||||
}
|
||||
|
||||
const callerClient = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser();
|
||||
if (userError || !userData?.user) {
|
||||
return { ok: false, status: 401, message: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
return { ok: false, status: 500, message: profileError.message };
|
||||
}
|
||||
|
||||
if (profile?.role !== 'team') {
|
||||
return { ok: false, status: 403, message: 'Forbidden' };
|
||||
}
|
||||
|
||||
return { ok: true, supabaseUrl };
|
||||
}
|
||||
|
||||
async function countRows(client, table) {
|
||||
const { count, error } = await client.from(table).select('id', { count: 'exact', head: true });
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
}
|
||||
|
||||
async function listStorageFolder(storageClient, bucketId, folder = '') {
|
||||
const pageSize = 100;
|
||||
let offset = 0;
|
||||
const entries = [];
|
||||
|
||||
while (true) {
|
||||
const { data, error } = await storageClient.from(bucketId).list(folder, {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
sortBy: { column: 'name', order: 'asc' },
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
if (!data?.length) break;
|
||||
|
||||
entries.push(...data);
|
||||
if (data.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isStorageFile(entry) {
|
||||
return Boolean(entry?.metadata && typeof entry.metadata === 'object' && 'size' in entry.metadata);
|
||||
}
|
||||
|
||||
async function getBucketUsage(storageClient, bucketId, folder = '') {
|
||||
let totalBytes = 0;
|
||||
let totalFiles = 0;
|
||||
|
||||
const entries = await listStorageFolder(storageClient, bucketId, folder);
|
||||
for (const entry of entries) {
|
||||
if (isStorageFile(entry)) {
|
||||
totalFiles += 1;
|
||||
totalBytes += Number(entry.metadata.size || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childFolder = folder ? `${folder}/${entry.name}` : entry.name;
|
||||
const childUsage = await getBucketUsage(storageClient, bucketId, childFolder);
|
||||
totalFiles += childUsage.totalFiles;
|
||||
totalBytes += childUsage.totalBytes;
|
||||
}
|
||||
|
||||
return { totalBytes, totalFiles };
|
||||
}
|
||||
|
||||
async function getStorageUsage(admin) {
|
||||
const { data: buckets, error } = await admin.storage.listBuckets();
|
||||
if (error) throw error;
|
||||
|
||||
const bucketEntries = [];
|
||||
let totalBytes = 0;
|
||||
let totalFiles = 0;
|
||||
|
||||
for (const bucket of buckets || []) {
|
||||
const bucketUsage = await getBucketUsage(admin.storage, bucket.id);
|
||||
totalBytes += bucketUsage.totalBytes;
|
||||
totalFiles += bucketUsage.totalFiles;
|
||||
bucketEntries.push({ bucketId: bucket.id, bytes: bucketUsage.totalBytes });
|
||||
}
|
||||
|
||||
bucketEntries.sort((a, b) => b.bytes - a.bytes);
|
||||
return { totalBytes, totalFiles, buckets: bucketEntries };
|
||||
}
|
||||
|
||||
async function getSupabaseUsageSnapshot(supabaseUrl, overridesResult) {
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const databaseBytesFallback = toNumber(process.env.SUPABASE_DB_SIZE_BYTES);
|
||||
const egressBytesFallback = toNumber(process.env.SUPABASE_EGRESS_BYTES);
|
||||
const overrides = overridesResult?.data || null;
|
||||
const egressBytes = toNumber(overrides?.supabase_egress_bytes) ?? egressBytesFallback;
|
||||
|
||||
if (!serviceRoleKey) {
|
||||
return {
|
||||
configured: false,
|
||||
message: 'Set SUPABASE_SERVICE_ROLE_KEY on Vercel to show live Supabase usage.',
|
||||
quotas: [
|
||||
makeQuota('storage', 'Storage', null, SUPABASE_LIMITS.storageBytes, 'bytes', 'Live storage usage is not configured.', 'unavailable'),
|
||||
makeQuota('database', 'Database size', databaseBytesFallback, SUPABASE_LIMITS.databaseBytes, 'bytes', databaseBytesFallback === null ? 'Run the latest migration or set SUPABASE_DB_SIZE_BYTES manually.' : 'Manual value from env.', databaseBytesFallback === null ? 'unavailable' : 'manual'),
|
||||
makeQuota('egress', 'Egress', egressBytes, SUPABASE_LIMITS.egressBytes, 'bytes', egressBytes === null ? 'Set this on the Server Status page when you want to track current egress manually.' : 'Manual value saved from Server Status.', egressBytes === null ? 'unavailable' : 'manual'),
|
||||
],
|
||||
stats: [],
|
||||
};
|
||||
}
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceRoleKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const [
|
||||
storageUsage,
|
||||
companyCount,
|
||||
projectCount,
|
||||
taskCount,
|
||||
brandBookCount,
|
||||
profileCount,
|
||||
databaseSizeResult,
|
||||
] = await Promise.all([
|
||||
getStorageUsage(admin),
|
||||
countRows(admin, 'companies'),
|
||||
countRows(admin, 'projects'),
|
||||
countRows(admin, 'tasks'),
|
||||
countRows(admin, 'brand_books'),
|
||||
countRows(admin, 'profiles'),
|
||||
admin.rpc('get_database_size_bytes'),
|
||||
]);
|
||||
|
||||
const databaseBytes = databaseSizeResult.error
|
||||
? null
|
||||
: toNumber(databaseSizeResult.data);
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
message: null,
|
||||
quotas: [
|
||||
makeQuota('storage', 'Storage', storageUsage.totalBytes, SUPABASE_LIMITS.storageBytes, 'bytes', `${storageUsage.totalFiles} file${storageUsage.totalFiles === 1 ? '' : 's'} across ${storageUsage.buckets.length} bucket${storageUsage.buckets.length === 1 ? '' : 's'}.`),
|
||||
makeQuota(
|
||||
'database',
|
||||
'Database size',
|
||||
databaseBytes,
|
||||
SUPABASE_LIMITS.databaseBytes,
|
||||
'bytes',
|
||||
databaseBytes === null
|
||||
? 'Run the latest Supabase migration to enable live database-size reporting.'
|
||||
: 'Live reading from Supabase.',
|
||||
databaseBytes === null ? 'unavailable' : 'live'
|
||||
),
|
||||
makeQuota('egress', 'Egress', egressBytes, SUPABASE_LIMITS.egressBytes, 'bytes', egressBytes === null ? 'Set this on the Server Status page when you want to track current egress manually.' : 'Manual value saved from Server Status.', egressBytes === null ? 'unavailable' : 'manual'),
|
||||
],
|
||||
stats: [
|
||||
{ label: 'Storage Files', value: storageUsage.totalFiles },
|
||||
{ label: 'Buckets', value: storageUsage.buckets.length },
|
||||
{ label: 'Companies', value: companyCount },
|
||||
{ label: 'Projects', value: projectCount },
|
||||
{ label: 'Jobs', value: taskCount },
|
||||
{ label: 'Users', value: profileCount },
|
||||
{ label: 'Brand Books', value: brandBookCount },
|
||||
],
|
||||
buckets: storageUsage.buckets.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
async function getVercelUsageSnapshot() {
|
||||
const vercelToken = process.env.VERCEL_TOKEN;
|
||||
const teamId = process.env.VERCEL_TEAM_ID || process.env.VERCEL_ORG_ID || '';
|
||||
const projectId = process.env.VERCEL_PROJECT_ID || '';
|
||||
|
||||
const fastDataTransferBytes = toNumber(process.env.VERCEL_FAST_DATA_TRANSFER_BYTES);
|
||||
const edgeRequests = toNumber(process.env.VERCEL_EDGE_REQUESTS);
|
||||
const functionInvocations = toNumber(process.env.VERCEL_FUNCTION_INVOCATIONS);
|
||||
const activeCpuHours = toNumber(process.env.VERCEL_ACTIVE_CPU_HOURS);
|
||||
|
||||
const quotas = [
|
||||
makeQuota('fast-data-transfer', 'Fast Data Transfer', fastDataTransferBytes, VERCEL_LIMITS.fastDataTransferBytes, 'bytes', fastDataTransferBytes === null ? 'Optional: set VERCEL_FAST_DATA_TRANSFER_BYTES to visualize current usage.' : 'Manual value from env.', fastDataTransferBytes === null ? 'unavailable' : 'manual'),
|
||||
makeQuota('edge-requests', 'Edge Requests', edgeRequests, VERCEL_LIMITS.edgeRequests, 'count', edgeRequests === null ? 'Optional: set VERCEL_EDGE_REQUESTS to visualize current usage.' : 'Manual value from env.', edgeRequests === null ? 'unavailable' : 'manual'),
|
||||
makeQuota('function-invocations', 'Function Invocations', functionInvocations, VERCEL_LIMITS.functionInvocations, 'count', functionInvocations === null ? 'Optional: set VERCEL_FUNCTION_INVOCATIONS to visualize current usage.' : 'Manual value from env.', functionInvocations === null ? 'unavailable' : 'manual'),
|
||||
makeQuota('active-cpu', 'Functions Active CPU', activeCpuHours, VERCEL_LIMITS.activeCpuHours, 'hours', activeCpuHours === null ? 'Optional: set VERCEL_ACTIVE_CPU_HOURS to visualize current usage.' : 'Manual value from env.', activeCpuHours === null ? 'unavailable' : 'manual'),
|
||||
];
|
||||
|
||||
if (!vercelToken) {
|
||||
return {
|
||||
configured: false,
|
||||
message: 'Set VERCEL_TOKEN on Vercel to show project and deployment counts.',
|
||||
quotas,
|
||||
stats: [],
|
||||
};
|
||||
}
|
||||
|
||||
const authHeaders = { Authorization: `Bearer ${vercelToken}` };
|
||||
const teamQuery = teamId ? `?teamId=${encodeURIComponent(teamId)}` : '';
|
||||
const deploymentParams = new URLSearchParams({
|
||||
limit: '20',
|
||||
since: String(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
if (projectId) deploymentParams.set('projectId', projectId);
|
||||
if (teamId) deploymentParams.set('teamId', teamId);
|
||||
|
||||
const [projectsResponse, deploymentsResponse] = await Promise.all([
|
||||
fetch(`https://api.vercel.com/v9/projects${teamQuery}`, { headers: authHeaders }),
|
||||
fetch(`https://api.vercel.com/v6/deployments?${deploymentParams.toString()}`, { headers: authHeaders }),
|
||||
]);
|
||||
|
||||
let projectCount = null;
|
||||
let deployments30d = null;
|
||||
let deploymentsToday = null;
|
||||
|
||||
if (projectsResponse.ok) {
|
||||
const projectsJson = await projectsResponse.json();
|
||||
projectCount = Array.isArray(projectsJson.projects) ? projectsJson.projects.length : null;
|
||||
}
|
||||
|
||||
if (deploymentsResponse.ok) {
|
||||
const deploymentsJson = await deploymentsResponse.json();
|
||||
const deployments = Array.isArray(deploymentsJson.deployments) ? deploymentsJson.deployments : [];
|
||||
deployments30d = deployments.length;
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
deploymentsToday = deployments.filter(deployment => {
|
||||
const createdAt = Number(deployment.createdAt || 0);
|
||||
return createdAt >= todayStart.getTime();
|
||||
}).length;
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
message: null,
|
||||
quotas,
|
||||
stats: [
|
||||
...(projectCount === null ? [] : [{ label: 'Projects', value: projectCount }]),
|
||||
...(deployments30d === null ? [] : [{ label: 'Deployments (30d)', value: deployments30d }]),
|
||||
...(deploymentsToday === null ? [] : [{ label: 'Deployments Today', value: deploymentsToday }]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function applyManualOverrides(services, overrides) {
|
||||
if (!overrides) return services;
|
||||
|
||||
const updateQuota = (serviceKey, quotaId, value, note) => {
|
||||
const service = services[serviceKey];
|
||||
if (!service) return;
|
||||
service.usage.quotas = (service.usage.quotas || []).map(quota =>
|
||||
quota.id === quotaId
|
||||
? {
|
||||
...quota,
|
||||
used: toNumber(value),
|
||||
note: toNumber(value) === null ? quota.note : note,
|
||||
source: toNumber(value) === null ? quota.source : 'manual',
|
||||
}
|
||||
: quota
|
||||
);
|
||||
};
|
||||
|
||||
updateQuota('supabase', 'egress', overrides.supabase_egress_bytes, 'Manual value saved from Server Status.');
|
||||
updateQuota('vercel', 'fast-data-transfer', overrides.vercel_fast_data_transfer_bytes, 'Manual value saved from Server Status.');
|
||||
updateQuota('vercel', 'edge-requests', overrides.vercel_edge_requests, 'Manual value saved from Server Status.');
|
||||
updateQuota('vercel', 'function-invocations', overrides.vercel_function_invocations, 'Manual value saved from Server Status.');
|
||||
updateQuota('vercel', 'active-cpu', overrides.vercel_active_cpu_hours, 'Manual value saved from Server Status.');
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (!['GET', 'POST'].includes(req.method)) {
|
||||
return json(res, 405, { error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader) {
|
||||
return json(res, 401, { error: 'Missing authorization header' });
|
||||
}
|
||||
|
||||
try {
|
||||
const authResult = await requireTeam(authHeader);
|
||||
if (!authResult.ok) {
|
||||
return json(res, authResult.status, { error: authResult.message });
|
||||
}
|
||||
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const admin = serviceRoleKey
|
||||
? createClient(authResult.supabaseUrl, serviceRoleKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (req.method === 'POST') {
|
||||
if (!admin) return json(res, 500, { error: 'SUPABASE_SERVICE_ROLE_KEY is required to save overrides.' });
|
||||
|
||||
const body = typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {});
|
||||
const payload = {
|
||||
id: true,
|
||||
supabase_egress_bytes: toNumber(body.supabase_egress_bytes),
|
||||
vercel_fast_data_transfer_bytes: toNumber(body.vercel_fast_data_transfer_bytes),
|
||||
vercel_edge_requests: toNumber(body.vercel_edge_requests),
|
||||
vercel_function_invocations: toNumber(body.vercel_function_invocations),
|
||||
vercel_active_cpu_hours: toNumber(body.vercel_active_cpu_hours),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { error } = await admin.from('server_status_overrides').upsert(payload);
|
||||
if (error) return json(res, 500, { error: error.message });
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
const overridesResult = admin
|
||||
? await admin.from('server_status_overrides').select('*').eq('id', true).maybeSingle()
|
||||
: { data: null, error: null };
|
||||
|
||||
const [supabaseStatus, vercelStatus, supabaseUsage, vercelUsage] = await Promise.all([
|
||||
fetchStatusSummary(STATUS_ENDPOINTS.supabase),
|
||||
fetchStatusSummary(STATUS_ENDPOINTS.vercel),
|
||||
getSupabaseUsageSnapshot(authResult.supabaseUrl, overridesResult),
|
||||
getVercelUsageSnapshot(),
|
||||
]);
|
||||
|
||||
const services = applyManualOverrides({
|
||||
supabase: {
|
||||
name: 'Supabase',
|
||||
provider: 'supabase',
|
||||
status: supabaseStatus,
|
||||
usage: supabaseUsage,
|
||||
limits: SUPABASE_LIMITS,
|
||||
},
|
||||
vercel: {
|
||||
name: 'Vercel',
|
||||
provider: 'vercel',
|
||||
status: vercelStatus,
|
||||
usage: vercelUsage,
|
||||
limits: VERCEL_LIMITS,
|
||||
},
|
||||
}, overridesResult.data);
|
||||
|
||||
return json(res, 200, {
|
||||
checkedAt: new Date().toISOString(),
|
||||
overrides: overridesResult.data || null,
|
||||
services,
|
||||
});
|
||||
} catch (error) {
|
||||
return json(res, 500, { error: error.message || 'Server status failed' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user