feature/file-manager-user-only-read
This commit is contained in:
@@ -456,6 +456,7 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
|
||||
// Métodos HTTP
|
||||
export const api = {
|
||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
|
||||
|
||||
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
|
||||
53
frontend/src/lib/api/dashboard/a76/audit_files.ts
Normal file
53
frontend/src/lib/api/dashboard/a76/audit_files.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { api } from '$lib/api';
|
||||
|
||||
const BASE_PATH = '/v1/a76/audit-log/files';
|
||||
|
||||
export interface AuditFileBreadcrumb {
|
||||
path: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface AuditFolderItem {
|
||||
path: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface AuditFileItem {
|
||||
path: string;
|
||||
display_name: string;
|
||||
size: number;
|
||||
last_modified?: string | null;
|
||||
}
|
||||
|
||||
export interface AuditFileListResponse {
|
||||
current_path: string;
|
||||
display_path: string;
|
||||
breadcrumbs: AuditFileBreadcrumb[];
|
||||
folders: AuditFolderItem[];
|
||||
files: AuditFileItem[];
|
||||
next_token?: string | null;
|
||||
}
|
||||
|
||||
export const AuditFilesAPI = {
|
||||
list: async (params?: {
|
||||
path?: string;
|
||||
continuation_token?: string;
|
||||
max_keys?: number;
|
||||
}): Promise<AuditFileListResponse> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.path) query.set('path', params.path);
|
||||
if (params?.continuation_token) query.set('continuation_token', params.continuation_token);
|
||||
if (params?.max_keys) query.set('max_keys', String(params.max_keys));
|
||||
|
||||
const qs = query.toString();
|
||||
const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH;
|
||||
const response = await api.get<AuditFileListResponse>(endpoint);
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(response.error || 'Failed to list tenant files');
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadBlob: (path: string) =>
|
||||
api.getBlob(`${BASE_PATH}/download?path=${encodeURIComponent(path)}`)
|
||||
};
|
||||
@@ -2,10 +2,11 @@
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { ScrollText, ListTodo } from 'lucide-svelte';
|
||||
import { ScrollText, ListTodo, FolderTree } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import BitacoraTab from './bitacora-tab.svelte';
|
||||
import TasksTab from './tasks-tab.svelte';
|
||||
import FilesTab from './files-tab.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -17,17 +18,19 @@
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
const t = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora';
|
||||
const raw = page.url.searchParams.get('tab');
|
||||
const t = raw === 'tasks' || raw === 'files' ? raw : 'bitacora';
|
||||
tabValue = t;
|
||||
});
|
||||
|
||||
function onTabValueChange(v: string) {
|
||||
const fromUrl = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora';
|
||||
const raw = page.url.searchParams.get('tab');
|
||||
const fromUrl = raw === 'tasks' || raw === 'files' ? raw : 'bitacora';
|
||||
if (v === fromUrl) return;
|
||||
|
||||
const u = new URL(page.url.href);
|
||||
if (v === 'tasks') {
|
||||
u.searchParams.set('tab', 'tasks');
|
||||
if (v === 'tasks' || v === 'files') {
|
||||
u.searchParams.set('tab', v);
|
||||
} else {
|
||||
u.searchParams.delete('tab');
|
||||
}
|
||||
@@ -53,7 +56,7 @@
|
||||
onValueChange={onTabValueChange}
|
||||
class="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<Tabs.List class="grid w-full grid-cols-2">
|
||||
<Tabs.List class="grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="bitacora">
|
||||
<ScrollText class="mr-2 h-4 w-4" />
|
||||
{m['sidebar.audit_logs_tab_bitacora']()}
|
||||
@@ -62,6 +65,10 @@
|
||||
<ListTodo class="mr-2 h-4 w-4" />
|
||||
{m['sidebar.audit_logs_tab_tasks']()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="files">
|
||||
<FolderTree class="mr-2 h-4 w-4" />
|
||||
{m['sidebar.audit_logs_tab_files']()}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="bitacora" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -74,5 +81,10 @@
|
||||
<TasksTab />
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="files" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{#if tabValue === 'files'}
|
||||
<FilesTab />
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,6 @@ import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ url }) => {
|
||||
const tab = url.searchParams.get('tab');
|
||||
const initialTab = tab === 'tasks' ? 'tasks' : 'bitacora';
|
||||
const initialTab = tab === 'tasks' || tab === 'files' ? tab : 'bitacora';
|
||||
return { initialTab };
|
||||
};
|
||||
|
||||
205
frontend/src/routes/dashboard/audit_logs/files-tab.svelte
Normal file
205
frontend/src/routes/dashboard/audit_logs/files-tab.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Folder, FileDown, RefreshCw } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import {
|
||||
AuditFilesAPI,
|
||||
type AuditFileBreadcrumb,
|
||||
type AuditFileItem,
|
||||
type AuditFolderItem
|
||||
} from '$lib/api/dashboard/a76/audit_files';
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let currentPath = $state('');
|
||||
let displayPath = $state('');
|
||||
let breadcrumbs = $state<AuditFileBreadcrumb[]>([]);
|
||||
let folders = $state<AuditFolderItem[]>([]);
|
||||
let files = $state<AuditFileItem[]>([]);
|
||||
|
||||
async function loadPath(path = '') {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await AuditFilesAPI.list({ path, max_keys: 200 });
|
||||
currentPath = res.current_path ?? '';
|
||||
displayPath = res.display_path ?? '';
|
||||
breadcrumbs = res.breadcrumbs ?? [];
|
||||
folders = res.folders ?? [];
|
||||
files = res.files ?? [];
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (!bytes || bytes < 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let idx = 0;
|
||||
while (value >= 1024 && idx < units.length - 1) {
|
||||
value /= 1024;
|
||||
idx++;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(path: string, filename: string) {
|
||||
try {
|
||||
const blob = await AuditFilesAPI.downloadBlob(path);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadPath('');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{m['sidebar.audit_logs_files_title']()}</h2>
|
||||
<p class="text-sm text-muted-foreground">{displayPath || m['sidebar.audit_logs_files_root']()}</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => void loadPath(currentPath)} disabled={loading}>
|
||||
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
|
||||
{m['sidebar.audit_logs_files_refresh']()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="py-2">
|
||||
<div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
{#each breadcrumbs as crumb, idx}
|
||||
{#if idx > 0}
|
||||
<span class="mx-1 text-[10px]">/</span>
|
||||
{/if}
|
||||
{#if idx === breadcrumbs.length - 1}
|
||||
<span
|
||||
class="max-w-[240px] truncate font-medium text-foreground sm:max-w-[320px]"
|
||||
>
|
||||
{crumb.display_name}
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="max-w-[180px] truncate hover:text-foreground hover:underline underline-offset-2"
|
||||
onclick={() => void loadPath(crumb.path)}
|
||||
disabled={loading}
|
||||
>
|
||||
{crumb.display_name}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="text-base">{m['sidebar.audit_logs_files_list_title']()}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="min-h-0 flex-1 overflow-auto p-0">
|
||||
{#if error}
|
||||
<div class="m-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||
{m['sidebar.audit_logs_files_error_prefix']()} {error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full overflow-x-auto">
|
||||
<Table.Root class="min-w-full text-sm">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="min-w-[200px]">
|
||||
{m['sidebar.audit_logs_files_col_name']()}
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[110px]">
|
||||
{m['sidebar.audit_logs_files_col_size']()}
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[160px]">
|
||||
{m['sidebar.audit_logs_files_col_modified']()}
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[120px] text-left">
|
||||
{m['sidebar.audit_logs_files_col_actions']()}
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if loading}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
|
||||
{m['sidebar.audit_logs_files_loading']()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if folders.length === 0 && files.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
|
||||
{m['sidebar.audit_logs_files_empty']()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each folders as folder}
|
||||
<Table.Row
|
||||
class="cursor-pointer hover:bg-muted/30"
|
||||
onclick={() => void loadPath(folder.path)}
|
||||
>
|
||||
<Table.Cell class="max-w-[260px] font-medium">
|
||||
<span class="inline-flex items-center gap-2 truncate text-left">
|
||||
<Folder class="h-4 w-4 flex-shrink-0 text-amber-500" />
|
||||
<span class="truncate">{folder.display_name}</span>
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">—</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">—</Table.Cell>
|
||||
<Table.Cell class="text-left text-muted-foreground">—</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{#each files as file}
|
||||
<Table.Row>
|
||||
<Table.Cell class="max-w-[260px] font-medium">
|
||||
<span class="truncate">{file.display_name}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatSize(file.size)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(file.last_modified)}</Table.Cell>
|
||||
<Table.Cell class="text-left">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="h-8 w-8"
|
||||
onclick={() => void downloadFile(file.path, file.display_name)}
|
||||
>
|
||||
<FileDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
Reference in New Issue
Block a user