5967 lines
318 KiB
HTML
5967 lines
318 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
{% set active_tab = request.args.get('tab', 'objects') %}
|
|
<div class="d-flex flex-column gap-4 h-100">
|
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
|
|
<div>
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb mb-2">
|
|
<li class="breadcrumb-item"><a href="{{ url_for('ui.buckets_overview') }}" class="text-decoration-none">Buckets</a></li>
|
|
<li class="breadcrumb-item active" aria-current="page">{{ bucket_name }}</li>
|
|
</ol>
|
|
</nav>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="bucket-icon" style="width: 48px; height: 48px; border-radius: 12px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1 class="h3 fw-bold mb-1">{{ bucket_name }}</h1>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="badge {{ 'text-bg-success' if versioning_enabled else 'text-bg-secondary' }} rounded-pill">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/>
|
|
</svg>
|
|
{{ 'Versioning On' if versioning_enabled else 'Versioning Off' }}
|
|
</span>
|
|
<span class="text-muted small" id="object-count-badge">
|
|
<span class="spinner-border spinner-border-sm" role="status" style="width: 0.75rem; height: 0.75rem;"></span>
|
|
Loading...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteBucketModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="nav nav-tabs border-bottom-0" id="bucketTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link {{ 'active' if active_tab == 'objects' else '' }}" id="objects-tab" data-bs-toggle="tab" data-bs-target="#objects-pane" type="button" role="tab" aria-controls="objects-pane" aria-selected="{{ 'true' if active_tab == 'objects' else 'false' }}">
|
|
Objects
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link {{ 'active' if active_tab == 'properties' else '' }}" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties-pane" type="button" role="tab" aria-controls="properties-pane" aria-selected="{{ 'true' if active_tab == 'properties' else 'false' }}">
|
|
Properties
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link {{ 'active' if active_tab == 'permissions' else '' }}" id="permissions-tab" data-bs-toggle="tab" data-bs-target="#permissions-pane" type="button" role="tab" aria-controls="permissions-pane" aria-selected="{{ 'true' if active_tab == 'permissions' else 'false' }}">
|
|
Permissions
|
|
</button>
|
|
</li>
|
|
{% if can_manage_replication %}
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link {{ 'active' if active_tab == 'replication' else '' }}" id="replication-tab" data-bs-toggle="tab" data-bs-target="#replication-pane" type="button" role="tab" aria-controls="replication-pane" aria-selected="{{ 'true' if active_tab == 'replication' else 'false' }}">
|
|
Replication
|
|
</button>
|
|
</li>
|
|
{% endif %}
|
|
{% if can_edit_policy %}
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link {{ 'active' if active_tab == 'lifecycle' else '' }}" id="lifecycle-tab" data-bs-toggle="tab" data-bs-target="#lifecycle-pane" type="button" role="tab" aria-controls="lifecycle-pane" aria-selected="{{ 'true' if active_tab == 'lifecycle' else 'false' }}">
|
|
Lifecycle
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link {{ 'active' if active_tab == 'cors' else '' }}" id="cors-tab" data-bs-toggle="tab" data-bs-target="#cors-pane" type="button" role="tab" aria-controls="cors-pane" aria-selected="{{ 'true' if active_tab == 'cors' else 'false' }}">
|
|
CORS
|
|
</button>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
|
|
<div class="tab-content" id="bucketTabsContent">
|
|
|
|
<div class="tab-pane fade {{ 'show active' if active_tab == 'objects' else '' }}" id="objects-pane" role="tabpanel" aria-labelledby="objects-tab" tabindex="0">
|
|
<div class="row g-4">
|
|
<div class="col-lg-7">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-body">
|
|
<div class="objects-header-responsive">
|
|
<span class="fw-semibold header-title">Objects</span>
|
|
<div class="header-actions">
|
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
|
</svg>
|
|
Upload
|
|
</button>
|
|
<div class="position-relative search-wrapper">
|
|
<input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects" style="max-width: 180px;" />
|
|
</div>
|
|
<div class="bulk-actions d-none" id="bulk-actions-wrapper">
|
|
<button class="btn btn-outline-danger btn-sm" type="button" data-bulk-delete-trigger disabled>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="bi bi-check2-square me-1" viewBox="0 0 16 16">
|
|
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" />
|
|
<path d="m4.646 8.646-.708.708L7 12.414l5.354-5.354-.708-.708L7 10.293z" />
|
|
</svg>
|
|
<span data-bulk-delete-label>Delete</span>
|
|
</button>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bulk-download-trigger disabled>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download me-1" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
|
</svg>
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<nav id="folder-breadcrumb" class="mt-2 d-none" aria-label="Folder navigation">
|
|
<ol class="breadcrumb mb-0 small">
|
|
<li class="breadcrumb-item">
|
|
<a href="#" data-folder-nav="" class="text-decoration-none">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M.54 3.87.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3H14.5A1.5 1.5 0 0 1 16 4.5v1.384l-4.578-.724a3 3 0 0 0-2.856 1.13l-2.847 3.55A3 3 0 0 0 5 11.373V14H2.5A1.5 1.5 0 0 1 1 12.5v-8A1.5 1.5 0 0 1 2.5 3H6c-.314 0-.6-.172-.742-.438l-.328-.658A.5.5 0 0 0 4.47 1.657z"/>
|
|
</svg>
|
|
Root
|
|
</a>
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
<div id="filter-warning" class="alert alert-warning fade show d-none py-1 px-2 mt-2 mb-0 small d-flex align-items-center" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
|
</svg>
|
|
<span id="filter-warning-text" class="me-2">Filtering loaded objects only. Not all objects have been loaded yet.</span>
|
|
<button type="button" class="btn-close ms-auto flex-shrink-0" style="font-size: 0.5rem;" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="table-responsive objects-table-container drop-zone"
|
|
id="objects-drop-zone"
|
|
data-bucket="{{ bucket_name }}"
|
|
data-versioning="{{ 'true' if versioning_enabled else 'false' }}"
|
|
data-objects-api="{{ objects_api_url }}"
|
|
data-bulk-delete-endpoint="{{ url_for('ui.bulk_delete_objects', bucket_name=bucket_name) }}"
|
|
data-bulk-download-endpoint="{{ url_for('ui.bulk_download_objects', bucket_name=bucket_name) }}"
|
|
data-folders-url="{{ folders_url }}"
|
|
data-buckets-for-copy-url="{{ buckets_for_copy_url }}"
|
|
>
|
|
<table class="table table-hover align-middle mb-0" id="objects-table" style="table-layout: fixed;">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th scope="col" class="text-center" style="width: 3rem;">
|
|
<input class="form-check-input" type="checkbox" data-select-all aria-label="Select all objects" />
|
|
</th>
|
|
<th scope="col">Key</th>
|
|
<th scope="col" class="text-end" style="width: 6rem;">Size</th>
|
|
<th scope="col" class="text-end" style="width: 5.5rem;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr id="objects-loading-row">
|
|
<td colspan="4" class="py-5">
|
|
<div class="text-center">
|
|
<div class="spinner-border text-primary mb-3" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="text-muted mb-0">Loading objects...</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div id="scroll-sentinel" style="height: 1px;"></div>
|
|
</div>
|
|
<div id="pagination-controls" class="card-footer d-flex justify-content-between align-items-center py-1 px-3 bg-body-tertiary" style="font-size: 0.75rem;">
|
|
<div id="load-more-container" class="d-flex align-items-center gap-2">
|
|
<div id="load-more-spinner" class="d-none">
|
|
<div class="spinner-border spinner-border-sm text-primary me-1" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
<span id="load-more-status" class="text-muted"></span>
|
|
<span id="folder-view-status" class="text-muted d-none"></span>
|
|
<button id="load-more-btn" class="btn btn-link btn-sm p-0 d-none" style="font-size: 0.75rem;">Load more</button>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-1">
|
|
<span class="text-muted">Batch</span>
|
|
<select id="page-size-select" class="form-select form-select-sm py-0" style="width: auto; font-size: 0.75rem;" title="Number of objects to load per batch">
|
|
<option value="1000">1K</option>
|
|
<option value="5000" selected>5K</option>
|
|
<option value="10000">10K</option>
|
|
<option value="25000">25K</option>
|
|
<option value="50000">50K</option>
|
|
<option value="75000">75K</option>
|
|
<option value="100000">100K</option>
|
|
</select>
|
|
<span class="text-muted">per batch</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-5">
|
|
<div class="card shadow-sm sticky-top preview-card" style="border-radius: 1rem;">
|
|
<div class="card-header bg-body d-flex align-items-center gap-2" style="border-radius: 1rem 1rem 0 0;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Object Details</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="preview-empty" class="empty-state py-4">
|
|
<div class="empty-state-icon mx-auto" style="width: 64px; height: 64px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
|
</svg>
|
|
</div>
|
|
<p class="text-muted mb-0">Select an object to view details</p>
|
|
</div>
|
|
<div id="preview-panel" class="d-none">
|
|
<div class="d-flex align-items-start gap-3 mb-3 p-3 rounded" style="background: var(--myfsio-preview-bg);">
|
|
<div class="file-icon flex-shrink-0" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 8px; background: var(--myfsio-hover-bg);">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="min-w-0 flex-grow-1">
|
|
<h2 class="h6 mb-1 text-break fw-semibold" id="preview-key"></h2>
|
|
<div class="text-muted small" id="preview-size"></div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2 mb-3">
|
|
<a class="btn btn-primary btn-sm flex-grow-1" id="downloadButton" target="_blank">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
|
</svg>
|
|
Download
|
|
</a>
|
|
<button class="btn btn-outline-secondary btn-sm" id="presignButton">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
|
</svg>
|
|
Share Link
|
|
</button>
|
|
</div>
|
|
<div class="p-3 rounded mb-3" style="background: var(--myfsio-preview-bg);">
|
|
<dl class="row small mb-0">
|
|
<dt class="col-5 text-muted fw-normal">Last modified</dt>
|
|
<dd class="col-7 mb-2 fw-medium" id="preview-modified"></dd>
|
|
<dt class="col-5 text-muted fw-normal">ETag</dt>
|
|
<dd class="col-7 mb-0"><code id="preview-etag" class="small" style="font-size: 0.75rem;"></code></dd>
|
|
</dl>
|
|
</div>
|
|
<div id="preview-metadata" class="mb-3 d-none">
|
|
<div class="d-flex align-items-center gap-2 mb-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
|
<path d="M3 2v4.586l7 7L14.586 9l-7-7H3zM2 2a1 1 0 0 1 1-1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 2 6.586V2z"/>
|
|
<path d="M5.5 5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0 1a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/>
|
|
</svg>
|
|
<span class="fw-semibold small text-uppercase">Metadata</span>
|
|
</div>
|
|
<div id="preview-metadata-list" class="metadata-stack small p-3 rounded" style="background: var(--myfsio-preview-bg);"></div>
|
|
</div>
|
|
<div id="preview-tags" class="mb-3 d-none">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
|
<path d="M3 2v4.586l7 7L14.586 9l-7-7H3zM2 2a1 1 0 0 1 1-1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 2 6.586V2z"/>
|
|
<path d="M5.5 5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm0 1a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM1 7.086a1 1 0 0 0 .293.707L8.75 15.25l-.043.043a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 0 7.586V3a1 1 0 0 1 1-1v5.086z"/>
|
|
</svg>
|
|
<span class="fw-semibold small text-uppercase">Tags</span>
|
|
<span class="badge bg-secondary" id="preview-tags-count">0</span>
|
|
</div>
|
|
<button class="btn btn-outline-primary btn-sm" type="button" id="editTagsButton" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
|
</svg>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
<div id="preview-tags-list" class="d-flex flex-wrap gap-1"></div>
|
|
<div id="preview-tags-empty" class="text-muted small p-2 bg-body-tertiary rounded">No tags</div>
|
|
<div id="preview-tags-editor" class="d-none mt-2">
|
|
<div id="preview-tags-inputs" class="mb-2"></div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-sm btn-outline-secondary flex-grow-1" type="button" id="addTagRow">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
|
</svg>
|
|
Add Tag
|
|
</button>
|
|
<button class="btn btn-sm btn-primary" type="button" id="saveTagsButton">Save</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" id="cancelTagsButton">Cancel</button>
|
|
</div>
|
|
<div class="form-text mt-1">Maximum 10 tags. Keys and values up to 256 characters.</div>
|
|
</div>
|
|
</div>
|
|
<div id="version-panel" class="mb-3 d-none">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
|
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
|
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
|
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
|
</svg>
|
|
<span class="fw-semibold small text-uppercase">Versions</span>
|
|
</div>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" id="refreshVersionsButton" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div id="version-list" class="list-group list-group-flush small rounded overflow-hidden" style="border: 1px solid var(--myfsio-card-border);"></div>
|
|
</div>
|
|
<div class="preview-stage border rounded position-relative overflow-hidden" style="border-radius: 0.75rem !important;">
|
|
<div id="preview-placeholder" class="text-muted text-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="mb-2 opacity-50" viewBox="0 0 16 16">
|
|
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
|
|
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
|
|
</svg>
|
|
<div class="small">No preview available</div>
|
|
</div>
|
|
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
|
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
|
<iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-pane fade {{ 'show active' if active_tab == 'permissions' else '' }}" id="permissions-pane" role="tabpanel" aria-labelledby="permissions-tab" tabindex="0">
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm" id="bucket-policy-card">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Bucket Policy</span>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if bucket_policy %}
|
|
<div class="alert alert-info d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Policy attached</strong>
|
|
<p class="mb-0 small">A bucket policy is attached to this bucket. Access is granted via both IAM and bucket policy rules.</p>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>IAM only</strong>
|
|
<p class="mb-0 small">No bucket policy is attached. Access is controlled by IAM policies only.</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can_edit_policy %}
|
|
{% set preset_choice = 'custom' %}
|
|
{% if not bucket_policy %}
|
|
{% set preset_choice = 'private' %}
|
|
{% elif bucket_policy_text and bucket_policy_text.strip() == default_policy.strip() %}
|
|
{% set preset_choice = 'public' %}
|
|
{% endif %}
|
|
<form method="post" action="{{ url_for('ui.update_bucket_policy', bucket_name=bucket_name) }}" id="bucketPolicyForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="mode" value="upsert" id="policyMode" />
|
|
|
|
<div class="mb-4">
|
|
<label class="form-label fw-semibold d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.5.5 0 0 0-.708 0l-.914.914a.5.5 0 0 0 .707.708l.915-.914a.5.5 0 0 0 0-.708z"/>
|
|
<path fill-rule="evenodd" d="M8 8a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm-1 3a1 1 0 1 1 2 0 1 1 0 0 1-2 0z"/>
|
|
</svg>
|
|
Quick Presets
|
|
<span class="badge bg-secondary-subtle text-secondary ms-2 fw-normal" style="font-size: 0.65rem;">Choose a template</span>
|
|
</label>
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<button type="button" class="btn btn-outline-secondary preset-btn {% if preset_choice == 'private' %}active{% endif %}" data-preset="private">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
Private
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary preset-btn {% if preset_choice == 'public' %}active{% endif %}" data-preset="public">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8Zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022ZM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816ZM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"/>
|
|
</svg>
|
|
Public Read
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary preset-btn {% if preset_choice == 'custom' %}active{% endif %}" data-preset="custom">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M10.478 1.647a.5.5 0 1 0-.956-.294l-4 13a.5.5 0 0 0 .956.294l4-13zM4.854 4.146a.5.5 0 0 1 0 .708L1.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm6.292 0a.5.5 0 0 0 0 .708L14.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z"/>
|
|
</svg>
|
|
Custom JSON
|
|
</button>
|
|
</div>
|
|
<input type="hidden" id="policyPreset" name="preset" value="{{ preset_choice }}" data-default="{{ preset_choice }}">
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold d-flex align-items-center justify-content-between">
|
|
<span>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
|
|
<path d="M4.5 12.5A.5.5 0 0 1 5 12h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm0-2A.5.5 0 0 1 5 10h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm1.639-3.708 1.33.886 1.854-1.855a.25.25 0 0 1 .289-.047l1.888.974V8.5a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V8s1.54-1.274 1.639-1.208z"/>
|
|
</svg>
|
|
Policy Document (JSON)
|
|
</span>
|
|
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" id="formatPolicyBtn" title="Format JSON">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8l-3.147-3.146z"/>
|
|
</svg>
|
|
Format
|
|
</button>
|
|
</label>
|
|
<div class="position-relative">
|
|
<textarea class="form-control font-monospace" rows="14" name="policy_document" id="policyDocument" data-public-template='{{ default_policy | tojson }}' spellcheck="false" style="font-size: 0.85rem; line-height: 1.5; tab-size: 2;">{{ bucket_policy_text or default_policy }}</textarea>
|
|
<div id="policyValidationStatus" class="position-absolute top-0 end-0 m-2 d-none">
|
|
<span class="badge bg-success-subtle text-success" id="policyValidBadge">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
Valid JSON
|
|
</span>
|
|
<span class="badge bg-danger-subtle text-danger d-none" id="policyInvalidBadge">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
|
</svg>
|
|
Invalid JSON
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div id="policyErrorDetail" class="text-danger small mt-1 d-none"></div>
|
|
<div id="policyReadonlyHint" class="alert alert-secondary small py-2 px-3 mt-2 mb-0 d-none">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
This preset is read-only. Select <strong>Custom JSON</strong> to edit the policy manually.
|
|
</div>
|
|
<div class="form-text d-flex align-items-start gap-2 mt-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="flex-shrink-0 mt-1 text-muted" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
|
|
</svg>
|
|
<span>Use presets for common scenarios or select <strong>Custom JSON</strong> to write AWS IAM-style policy statements. The policy will be validated on save.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<button class="btn btn-primary" type="submit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v7.293l2.646-2.647a.5.5 0 0 1 .708.708l-3.5 3.5a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L7.5 9.293V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/>
|
|
</svg>
|
|
Save Policy
|
|
</button>
|
|
{% if bucket_policy %}
|
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deletePolicyModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete Policy
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
{% else %}
|
|
<div class="text-center py-4">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="text-muted mb-3" viewBox="0 0 16 16">
|
|
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0zM9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1zM4.5 9a.5.5 0 0 1 0-1h7a.5.5 0 0 1 0 1h-7zM4 10.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 1 0-1h4a.5.5 0 0 1 0 1h-4z"/>
|
|
</svg>
|
|
<p class="text-muted mb-0">You do not have permission to edit this policy.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card bg-body-tertiary border-0">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
About Bucket Policies
|
|
</h6>
|
|
<p class="small text-muted mb-3">
|
|
Bucket policies are JSON-based access policy documents that specify who can access your bucket and under what conditions.
|
|
</p>
|
|
|
|
<h6 class="small fw-semibold mb-2">Policy Types</h6>
|
|
<ul class="small text-muted mb-3 ps-3">
|
|
<li><strong>Private</strong> — No policy attached; only IAM users with explicit permissions can access.</li>
|
|
<li><strong>Public Read</strong> — Anyone can read objects, but only authenticated users can write.</li>
|
|
<li><strong>Custom</strong> — Fine-grained control using AWS IAM policy syntax.</li>
|
|
</ul>
|
|
|
|
<h6 class="small fw-semibold mb-2">Policy Elements</h6>
|
|
<ul class="small text-muted mb-0 ps-3">
|
|
<li><strong>Effect</strong> — Allow or Deny</li>
|
|
<li><strong>Principal</strong> — Who the policy applies to</li>
|
|
<li><strong>Action</strong> — Which S3 operations</li>
|
|
<li><strong>Resource</strong> — Bucket/object ARN</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4 mt-2">
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm" id="bucket-acl-card" data-acl-url="{{ acl_url }}">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1H7Zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.784 6A2.238 2.238 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.325 6.325 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1h4.216ZM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Access Control List (ACL)</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="acl-loading" class="text-center py-3">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
Loading ACL...
|
|
</div>
|
|
<div id="acl-content" class="d-none">
|
|
<div class="mb-3">
|
|
<label class="form-label small fw-semibold">Owner</label>
|
|
<div class="border rounded p-2 bg-body-secondary">
|
|
<code id="acl-owner" class="small"></code>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label small fw-semibold">Current Grants</label>
|
|
<div id="acl-grants-list" class="list-group list-group-flush small"></div>
|
|
</div>
|
|
{% if can_edit_policy %}
|
|
<div class="mb-3">
|
|
<label class="form-label small fw-semibold">Set Canned ACL</label>
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-set-acl="private">Private</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-set-acl="public-read">Public Read</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-set-acl="public-read-write">Public Read/Write</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-set-acl="authenticated-read">Authenticated Read</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="card bg-body-tertiary border-0">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
About ACLs
|
|
</h6>
|
|
<p class="small text-muted mb-3">Access Control Lists define who can access your bucket and what they can do.</p>
|
|
<h6 class="small fw-semibold mb-2">Canned ACLs</h6>
|
|
<ul class="small text-muted mb-0 ps-3">
|
|
<li><strong>private</strong> — Owner only</li>
|
|
<li><strong>public-read</strong> — Anyone can read</li>
|
|
<li><strong>authenticated-read</strong> — Authenticated users can read</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-pane fade {{ 'show active' if active_tab == 'properties' else '' }}" id="properties-pane" role="tabpanel" aria-labelledby="properties-tab" tabindex="0">
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm" id="bucket-versioning-card">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
|
|
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Versioning</span>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if versioning_enabled %}
|
|
<div class="alert alert-success d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Versioning is enabled</strong>
|
|
<p class="mb-0 small">All previous versions of objects are preserved. You can roll back accidental changes or deletions at any time.</p>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Versioning is suspended</strong>
|
|
<p class="mb-0 small">New object uploads overwrite existing objects. Enable versioning to preserve previous versions.</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can_manage_versioning %}
|
|
{% if versioning_enabled %}
|
|
<button class="btn btn-outline-danger" type="button" data-bs-toggle="modal" data-bs-target="#suspendVersioningModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
|
</svg>
|
|
Suspend Versioning
|
|
</button>
|
|
{% else %}
|
|
<form method="post" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="state" value="enable" />
|
|
<button class="btn btn-success" type="submit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
Enable Versioning
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="text-center py-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
<p class="text-muted mb-0 small">You do not have permission to modify versioning for this bucket.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if versioning_enabled %}
|
|
<div
|
|
class="card shadow-sm mt-4"
|
|
id="archived-objects-card"
|
|
data-archived-endpoint="{{ url_for('ui.archived_objects', bucket_name=bucket_name) }}"
|
|
>
|
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<div class="d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-warning me-2" viewBox="0 0 16 16">
|
|
<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Archived Objects</span>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="badge text-bg-secondary" data-archived-count>0 items</span>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-archived-refresh>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-3">Objects that have been deleted while versioning is enabled. Their previous versions remain available until you restore or purge them.</p>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th scope="col">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1 text-muted" viewBox="0 0 16 16">
|
|
<path d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>
|
|
</svg>
|
|
Key
|
|
</th>
|
|
<th scope="col">Latest Version</th>
|
|
<th scope="col" class="text-center">Versions</th>
|
|
<th scope="col" class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody data-archived-body>
|
|
<tr>
|
|
<td colspan="4" class="text-center text-muted py-4">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="mb-2 d-block mx-auto" viewBox="0 0 16 16">
|
|
<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
|
|
</svg>
|
|
No archived objects
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if encryption_enabled %}
|
|
<div class="card shadow-sm mt-4" id="bucket-encryption-card">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
|
<path d="M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99a1.5 1.5 0 1 1 2-1.415z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Default Encryption</span>
|
|
</div>
|
|
<div class="card-body">
|
|
{% set enc_rules = encryption_config.get('Rules', []) %}
|
|
{% set enc_default = enc_rules[0].get('ApplyServerSideEncryptionByDefault', {}) if enc_rules else {} %}
|
|
{% set enc_algorithm = enc_default.get('SSEAlgorithm', '') %}
|
|
{% set enc_kms_key = enc_default.get('KMSMasterKeyID', '') %}
|
|
|
|
{% if enc_algorithm %}
|
|
<div class="alert alert-success d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Default encryption enabled</strong>
|
|
<p class="mb-0 small">
|
|
{% if enc_algorithm == 'aws:kms' %}
|
|
Objects are encrypted with AWS KMS (SSE-KMS).
|
|
{% if enc_kms_key %}Key: <code class="small">{{ enc_kms_key[:20] }}...</code>{% endif %}
|
|
{% else %}
|
|
Objects are encrypted with AES-256 (SSE-S3).
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Default encryption disabled</strong>
|
|
<p class="mb-0 small">Objects are stored without default encryption. You can enable server-side encryption below.</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can_manage_encryption %}
|
|
<form method="post" action="{{ url_for('ui.update_bucket_encryption', bucket_name=bucket_name) }}" id="encryptionForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="action" value="enable" id="encryptionAction" />
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label fw-medium">Encryption Algorithm</label>
|
|
<div class="card border">
|
|
<div class="card-body p-0">
|
|
<div class="form-check p-3 border-bottom m-0">
|
|
<input class="form-check-input" type="radio" name="algorithm" id="algo_aes256" value="AES256" {{ 'checked' if enc_algorithm != 'aws:kms' else '' }}>
|
|
<label class="form-check-label w-100" for="algo_aes256">
|
|
<span class="fw-medium">AES-256 (SSE-S3)</span>
|
|
<div class="text-muted small">Server-side encryption with S3-managed keys. Recommended for most use cases.</div>
|
|
</label>
|
|
</div>
|
|
{% if kms_enabled %}
|
|
<div class="form-check p-3 m-0">
|
|
<input class="form-check-input" type="radio" name="algorithm" id="algo_kms" value="aws:kms" {{ 'checked' if enc_algorithm == 'aws:kms' else '' }}>
|
|
<label class="form-check-label w-100" for="algo_kms">
|
|
<span class="fw-medium">AWS KMS (SSE-KMS)</span>
|
|
<div class="text-muted small">Server-side encryption with KMS-managed keys. Provides audit trail and key rotation.</div>
|
|
</label>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if kms_enabled %}
|
|
<div class="mb-4" id="kmsKeySection" style="{{ 'display: none;' if enc_algorithm != 'aws:kms' else '' }}">
|
|
<label for="kms_key_id" class="form-label fw-medium">KMS Key</label>
|
|
<select class="form-select" id="kms_key_id" name="kms_key_id">
|
|
<option value="">Use default KMS key</option>
|
|
{% for key in kms_keys %}
|
|
<option value="{{ key.key_id }}" {{ 'selected' if key.key_id == enc_kms_key else '' }}>
|
|
{{ key.description or key.key_id }} ({{ key.key_id[:8] }}...)
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="form-text">Select a KMS key to encrypt objects. Leave empty to use the default key.</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<button class="btn btn-primary" type="submit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
|
</svg>
|
|
Save Encryption Settings
|
|
</button>
|
|
{% if enc_algorithm %}
|
|
<button type="button" class="btn btn-outline-danger" id="disableEncryptionBtn">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
Disable Encryption
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
{% else %}
|
|
<div class="text-center py-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
<p class="text-muted mb-0 small">You do not have permission to modify encryption settings for this bucket.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="card shadow-sm mt-4" id="bucket-quota-card">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Storage Quota</span>
|
|
</div>
|
|
<div class="card-body">
|
|
{% set max_bytes = bucket_quota.get('max_bytes') %}
|
|
{% set max_objects = bucket_quota.get('max_objects') %}
|
|
{% set has_quota = max_bytes is not none or max_objects is not none %}
|
|
{% set current_objects = bucket_stats.get('objects', 0) %}
|
|
{% set version_count = bucket_stats.get('version_count', 0) %}
|
|
{% set total_objects = bucket_stats.get('total_objects', current_objects) %}
|
|
{% set current_bytes = bucket_stats.get('bytes', 0) %}
|
|
{% set version_bytes = bucket_stats.get('version_bytes', 0) %}
|
|
{% set total_bytes = bucket_stats.get('total_bytes', current_bytes) %}
|
|
|
|
<div class="mb-4">
|
|
<h6 class="small fw-semibold mb-3">Current Usage</h6>
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<div class="border rounded p-3 text-center">
|
|
<div class="fs-4 fw-bold text-primary">{{ total_objects }}</div>
|
|
<div class="small text-muted">Total Objects</div>
|
|
{% if max_objects is not none %}
|
|
<div class="progress mt-2" style="height: 4px;">
|
|
{% set obj_pct = (total_objects / max_objects * 100) | int if max_objects > 0 else 0 %}
|
|
<div class="progress-bar {% if obj_pct >= 90 %}bg-danger{% elif obj_pct >= 75 %}bg-warning{% else %}bg-success{% endif %}" style="width: {{ [obj_pct, 100] | min }}%"></div>
|
|
</div>
|
|
<div class="small text-muted mt-1">{{ obj_pct }}% of {{ max_objects }} limit</div>
|
|
{% else %}
|
|
<div class="small text-muted mt-2">No limit</div>
|
|
{% endif %}
|
|
{% if version_count > 0 %}
|
|
<div class="small text-muted mt-1">
|
|
<span class="text-body-secondary">({{ current_objects }} current + {{ version_count }} versions)</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="border rounded p-3 text-center">
|
|
<div class="fs-4 fw-bold text-primary">{{ total_bytes | filesizeformat }}</div>
|
|
<div class="small text-muted">Total Storage</div>
|
|
{% if max_bytes is not none %}
|
|
<div class="progress mt-2" style="height: 4px;">
|
|
{% set bytes_pct = (total_bytes / max_bytes * 100) | int if max_bytes > 0 else 0 %}
|
|
<div class="progress-bar {% if bytes_pct >= 90 %}bg-danger{% elif bytes_pct >= 75 %}bg-warning{% else %}bg-success{% endif %}" style="width: {{ [bytes_pct, 100] | min }}%"></div>
|
|
</div>
|
|
<div class="small text-muted mt-1">{{ bytes_pct }}% of {{ max_bytes | filesizeformat }} limit</div>
|
|
{% else %}
|
|
<div class="small text-muted mt-2">No limit</div>
|
|
{% endif %}
|
|
{% if version_bytes > 0 %}
|
|
<div class="small text-muted mt-1">
|
|
<span class="text-body-secondary">({{ current_bytes | filesizeformat }} current + {{ version_bytes | filesizeformat }} versions)</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if has_quota %}
|
|
<div class="alert alert-info d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Storage quota enabled</strong>
|
|
<p class="mb-0 small">
|
|
{% if max_bytes is not none and max_objects is not none %}
|
|
Limited to {{ max_bytes | filesizeformat }} and {{ max_objects }} objects.
|
|
{% elif max_bytes is not none %}
|
|
Limited to {{ max_bytes | filesizeformat }} storage.
|
|
{% else %}
|
|
Limited to {{ max_objects }} objects.
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>No storage quota</strong>
|
|
<p class="mb-0 small">This bucket has no storage or object count limits. Set limits below to control usage.</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can_manage_quota %}
|
|
<form method="post" action="{{ url_for('ui.update_bucket_quota', bucket_name=bucket_name) }}" id="quotaForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
|
|
<div class="mb-3">
|
|
<label for="max_mb" class="form-label fw-medium">Maximum Storage Size</label>
|
|
<div class="input-group">
|
|
<input type="number" class="form-control" id="max_mb" name="max_mb"
|
|
value="{{ (max_bytes / 1048576) | int if max_bytes is not none else '' }}"
|
|
min="1" step="1" placeholder="Unlimited">
|
|
<span class="input-group-text">MB</span>
|
|
</div>
|
|
<div class="form-text">Minimum 1 MB. Leave empty for unlimited.</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label for="max_objects" class="form-label fw-medium">Maximum Object Count</label>
|
|
<input type="number" class="form-control" id="max_objects" name="max_objects"
|
|
value="{{ max_objects if max_objects is not none else '' }}"
|
|
min="0" step="1" placeholder="Unlimited">
|
|
<div class="form-text">Maximum number of objects allowed. Leave empty for unlimited.</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<button class="btn btn-primary" type="submit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/>
|
|
</svg>
|
|
Save Quota Settings
|
|
</button>
|
|
{% if has_quota %}
|
|
<button type="submit" class="btn btn-outline-danger" id="removeQuotaBtn" name="action" value="remove">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
Remove Quota
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
{% else %}
|
|
<div class="text-center py-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
<p class="text-muted mb-0 small">You do not have permission to modify quota settings for this bucket.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card bg-body-tertiary border-0">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
About Versioning
|
|
</h6>
|
|
<p class="small text-muted mb-3">
|
|
Versioning keeps multiple variants of an object in the same bucket. You can use versioning to preserve, retrieve, and restore every version of every object.
|
|
</p>
|
|
|
|
<h6 class="small fw-semibold mb-2">Benefits</h6>
|
|
<ul class="small text-muted mb-3 ps-3">
|
|
<li><strong>Recovery</strong> — Restore accidentally deleted or overwritten objects</li>
|
|
<li><strong>Audit Trail</strong> — Track changes to objects over time</li>
|
|
<li><strong>Compliance</strong> — Meet retention requirements</li>
|
|
</ul>
|
|
|
|
<h6 class="small fw-semibold mb-2">How It Works</h6>
|
|
<ul class="small text-muted mb-0 ps-3">
|
|
<li>Each upload creates a new version</li>
|
|
<li>Deleting adds a "delete marker"</li>
|
|
<li>Previous versions remain accessible</li>
|
|
<li>Storage costs apply to all versions</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{% if versioning_enabled %}
|
|
<div class="card bg-body-tertiary border-0 mt-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-warning me-2" viewBox="0 0 16 16">
|
|
<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
|
|
</svg>
|
|
About Archives
|
|
</h6>
|
|
<p class="small text-muted mb-0">
|
|
When you delete an object with versioning enabled, S3 inserts a delete marker instead of removing the object. The archived versions remain in the bucket and can be restored at any time.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if encryption_enabled %}
|
|
<div class="card bg-body-tertiary border-0 mt-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
|
</svg>
|
|
About Encryption
|
|
</h6>
|
|
<p class="small text-muted mb-3">
|
|
Server-side encryption protects data at rest. Objects are encrypted when stored and decrypted when retrieved.
|
|
</p>
|
|
|
|
<h6 class="small fw-semibold mb-2">Encryption Types</h6>
|
|
<ul class="small text-muted mb-3 ps-3">
|
|
<li><strong>SSE-S3 (AES-256)</strong> — S3-managed keys, automatic encryption</li>
|
|
<li><strong>SSE-KMS</strong> — KMS-managed keys with audit trail and key rotation</li>
|
|
</ul>
|
|
|
|
<h6 class="small fw-semibold mb-2">How It Works</h6>
|
|
<ul class="small text-muted mb-0 ps-3">
|
|
<li>New objects are encrypted using the default setting</li>
|
|
<li>Existing objects are not automatically re-encrypted</li>
|
|
<li>Decryption is transparent during download</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if can_manage_replication %}
|
|
<div class="tab-pane fade {{ 'show active' if active_tab == 'replication' else '' }}" id="replication-pane" role="tabpanel" aria-labelledby="replication-tab" tabindex="0">
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Replication Configuration</span>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if replication_rule and replication_rule.enabled %}
|
|
<div id="replication-status-alert" class="alert alert-success d-flex align-items-center mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-check-circle-fill flex-shrink-0 me-2" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Replication Active</strong> —
|
|
{% if replication_rule.mode == 'all' %}
|
|
All objects (existing + new) are being replicated.
|
|
{% else %}
|
|
New uploads to this bucket are automatically replicated.
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div id="replication-endpoint-warning" class="alert alert-danger d-none mb-4" role="alert">
|
|
<div class="d-flex align-items-start">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Replication Endpoint Unreachable</strong>
|
|
<p class="mb-0 small" id="replication-endpoint-error">The target endpoint is not responding. Replication is paused until the endpoint is available.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-4" id="replication-stats-cards" data-status-endpoint="{{ url_for('ui.get_replication_status', bucket_name=bucket_name) }}">
|
|
<div class="col-6 col-lg">
|
|
<div class="card bg-body-tertiary border-0 h-100">
|
|
<div class="card-body text-center py-3">
|
|
<div class="d-flex justify-content-center align-items-center mb-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="text-muted small text-uppercase">Synced</div>
|
|
<div class="fw-semibold fs-5 text-success" data-stat="synced">
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg">
|
|
<div class="card bg-body-tertiary border-0 h-100">
|
|
<div class="card-body text-center py-3">
|
|
<div class="d-flex justify-content-center align-items-center mb-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="text-muted small text-uppercase">Pending</div>
|
|
<div class="fw-semibold fs-5" data-stat="pending">
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg">
|
|
<div class="card bg-body-tertiary border-0 h-100">
|
|
<div class="card-body text-center py-3">
|
|
<div class="d-flex justify-content-center align-items-center mb-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-secondary" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="text-muted small text-uppercase">Orphaned</div>
|
|
<div class="fw-semibold fs-5" data-stat="orphaned">
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg">
|
|
<div class="card bg-body-tertiary border-0 h-100">
|
|
<div class="card-body text-center py-3">
|
|
<div class="d-flex justify-content-center align-items-center mb-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-info" viewBox="0 0 16 16">
|
|
<path d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="text-muted small text-uppercase">Data Synced</div>
|
|
<div class="fw-semibold small" data-stat="bytes">
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-lg">
|
|
<div class="card bg-body-tertiary border-0 h-100">
|
|
<div class="card-body text-center py-3">
|
|
<div class="d-flex justify-content-center align-items-center mb-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="text-muted small text-uppercase">Mode</div>
|
|
<div class="fw-semibold small">
|
|
{% if replication_rule.mode == 'all' %}All Objects{% else %}New Only{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert alert-light border small mb-4" id="replication-last-sync" style="display: none;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
|
</svg>
|
|
<strong>Last sync:</strong>
|
|
<span data-stat="last-sync-time"></span>
|
|
<span data-stat="last-sync-key"></span>
|
|
</div>
|
|
|
|
<div class="card border mb-4" id="replication-failures-card" style="display: none;"
|
|
data-failures-endpoint="{{ url_for('ui.get_replication_failures', bucket_name=bucket_name) }}"
|
|
data-retry-endpoint="{{ url_for('ui.retry_replication_failure', bucket_name=bucket_name, object_key='__KEY__') }}"
|
|
data-retry-all-endpoint="{{ url_for('ui.retry_all_replication_failures', bucket_name=bucket_name) }}"
|
|
data-dismiss-endpoint="{{ url_for('ui.dismiss_replication_failure', bucket_name=bucket_name, object_key='__KEY__') }}"
|
|
data-clear-endpoint="{{ url_for('ui.clear_replication_failures', bucket_name=bucket_name) }}">
|
|
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
|
<div class="d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-danger me-2" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
<span class="fw-semibold small">Failed Replications</span>
|
|
<span class="badge bg-danger ms-2" id="replication-failure-count">0</span>
|
|
</div>
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary btn-sm" id="retry-all-failures-btn" title="Retry All">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
|
|
</svg>
|
|
Retry All
|
|
</button>
|
|
<button class="btn btn-outline-secondary btn-sm" id="clear-failures-btn" title="Clear All">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0" style="table-layout: fixed;">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-3" style="width: 25%;">Object Key</th>
|
|
<th style="width: 35%;">Error</th>
|
|
<th style="width: 18%;">Last Attempt</th>
|
|
<th class="text-center" style="width: 10%;">Attempts</th>
|
|
<th class="text-end pe-3" style="width: 12%;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="replication-failures-body">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="px-3 py-2 border-top" id="replication-failures-pagination" style="display: none;">
|
|
<button class="btn btn-link btn-sm p-0" id="show-more-failures">Show more...</button>
|
|
<span class="text-muted small ms-2" id="failures-shown-count"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h6 class="text-muted text-uppercase small mb-3">Replication Target</h6>
|
|
<div class="card border mb-4">
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-auto">
|
|
<div class="rounded-circle bg-primary bg-opacity-10 d-flex align-items-center justify-content-center" style="width: 48px; height: 48px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
{% set target_conn = connections | selectattr("id", "equalto", replication_rule.target_connection_id) | first %}
|
|
<div class="fw-semibold">{{ target_conn.name if target_conn else 'Unknown Connection' }}</div>
|
|
<div class="text-muted small">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5 4a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1H5zm-.5 2.5A.5.5 0 0 1 5 6h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zM5 8a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1H5zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1H5z"/>
|
|
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2zm10-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
|
|
</svg>
|
|
{{ replication_rule.target_bucket }}
|
|
</div>
|
|
{% if target_conn %}
|
|
<div class="text-muted small">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
|
</svg>
|
|
{{ target_conn.endpoint_url }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="col-auto">
|
|
<span id="replication-status-badge" class="badge bg-success-subtle text-success px-3 py-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
<span id="replication-status-text">Enabled</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<a href="{{ url_for('ui.bucket_detail', bucket_name=bucket_name, tab='replication') }}" class="btn btn-outline-secondary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
Refresh
|
|
</a>
|
|
<form id="pause-replication-form" method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="action" value="pause">
|
|
<button type="submit" class="btn btn-outline-warning">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
|
</svg>
|
|
Pause Replication
|
|
</button>
|
|
</form>
|
|
{% if is_replication_admin %}
|
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#disableReplicationModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Remove Configuration
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% elif replication_rule and not replication_rule.enabled %}
|
|
<div class="alert alert-warning d-flex align-items-start mb-4" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Replication Paused</strong>
|
|
<p class="mb-1">Replication is configured but currently paused. New uploads will not be replicated until resumed.</p>
|
|
{% if replication_rule.mode == 'all' %}
|
|
<p class="mb-0 small text-dark"><strong>Tip:</strong> When you resume, any objects uploaded while paused will be automatically synced to the target.</p>
|
|
{% else %}
|
|
<p class="mb-0 small text-dark"><strong>Note:</strong> Objects uploaded while paused will not be synced (mode: new_only). Consider switching to "All Objects" mode if you need to sync missed uploads.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<h6 class="text-muted text-uppercase small mb-3">Replication Target</h6>
|
|
<div class="card border mb-4">
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-auto">
|
|
<div class="rounded-circle bg-warning bg-opacity-10 d-flex align-items-center justify-content-center" style="width: 48px; height: 48px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
{% set target_conn = connections | selectattr("id", "equalto", replication_rule.target_connection_id) | first %}
|
|
<div class="fw-semibold">{{ target_conn.name if target_conn else 'Unknown Connection' }}</div>
|
|
<div class="text-muted small">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5 4a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1H5zm-.5 2.5A.5.5 0 0 1 5 6h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zM5 8a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1H5zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1H5z"/>
|
|
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2zm10-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
|
|
</svg>
|
|
{{ replication_rule.target_bucket }}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<span class="badge bg-warning-subtle text-warning px-3 py-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
|
</svg>
|
|
Paused
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="action" value="resume">
|
|
<button type="submit" class="btn btn-primary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/>
|
|
</svg>
|
|
Resume Replication
|
|
</button>
|
|
</form>
|
|
{% if is_replication_admin %}
|
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#disableReplicationModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Remove Configuration
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% else %}
|
|
<div class="text-center py-4">
|
|
<div class="rounded-circle bg-body-tertiary d-inline-flex align-items-center justify-content-center mb-3" style="width: 64px; height: 64px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
</div>
|
|
{% if is_replication_admin %}
|
|
<h5 class="mb-2">Set Up Replication</h5>
|
|
<p class="text-muted mb-4">Automatically copy new objects from this bucket to a bucket in another S3-compatible service.</p>
|
|
{% else %}
|
|
<h5 class="mb-2">Replication Not Configured</h5>
|
|
<p class="text-muted mb-4">An administrator needs to configure replication settings for this bucket before you can enable it.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if is_replication_admin and connections %}
|
|
<form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="action" value="create">
|
|
|
|
<div class="mb-3">
|
|
<label for="target_connection_id" class="form-label fw-medium">Target Connection</label>
|
|
<select class="form-select" id="target_connection_id" name="target_connection_id" required>
|
|
<option value="" selected disabled>Select a connection...</option>
|
|
{% for conn in connections %}
|
|
<option value="{{ conn.id }}">{{ conn.name }} ({{ conn.endpoint_url }})</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="form-text">Select the remote service where objects should be replicated.</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label for="target_bucket" class="form-label fw-medium">Target Bucket Name</label>
|
|
<input type="text" class="form-control" id="target_bucket" name="target_bucket" required placeholder="e.g. my-backup-bucket" pattern="^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$" minlength="3" maxlength="63">
|
|
<div id="target_bucket_feedback" class="invalid-feedback"></div>
|
|
<div class="form-text">
|
|
<span id="target_bucket_hint">If the target bucket does not exist, it will be created automatically.</span>
|
|
<div class="mt-1 text-muted small">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
Rules: 3-63 characters, lowercase letters, numbers, hyphens, and periods only. Must start/end with letter or number.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="form-label fw-medium">Replication Mode</label>
|
|
<div class="card border">
|
|
<div class="card-body p-0">
|
|
<div class="form-check p-3 border-bottom m-0">
|
|
<input class="form-check-input" type="radio" name="replication_mode" id="mode_new_only" value="new_only" checked>
|
|
<label class="form-check-label w-100" for="mode_new_only">
|
|
<span class="fw-medium">New uploads only</span>
|
|
<div class="text-muted small">Only replicate objects uploaded after enabling replication. Existing objects will not be copied.</div>
|
|
</label>
|
|
</div>
|
|
<div class="form-check p-3 m-0">
|
|
<input class="form-check-input" type="radio" name="replication_mode" id="mode_all" value="all">
|
|
<label class="form-check-label w-100" for="mode_all">
|
|
<span class="fw-medium">All objects (existing + new)</span>
|
|
<div class="text-muted small">Replicate all existing objects immediately, plus all future uploads. <span class="text-warning">This may take time for large buckets.</span></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
Enable Replication
|
|
</button>
|
|
</form>
|
|
{% elif is_replication_admin %}
|
|
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>No connections configured.</strong>
|
|
<p class="mb-1">You need to add a remote connection before you can enable replication.</p>
|
|
<a href="{{ url_for('ui.connections_dashboard') }}" class="alert-link">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
|
</svg>
|
|
Add Connection
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted me-2" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
<span class="fw-semibold">About Replication</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<ul class="list-unstyled mb-0 small">
|
|
<li class="d-flex align-items-start mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
|
</svg>
|
|
<span class="text-muted">Choose to replicate <strong>new uploads only</strong> or <strong>all objects</strong> (including existing).</span>
|
|
</li>
|
|
<li class="d-flex align-items-start mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
<span class="text-muted"><strong>All changes sync</strong> — uploads, updates, and deletes are replicated.</span>
|
|
</li>
|
|
<li class="d-flex align-items-start mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-info flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
|
</svg>
|
|
<span class="text-muted">Replication happens <strong>asynchronously</strong> in the background.</span>
|
|
</li>
|
|
<li class="d-flex align-items-start">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-secondary flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
|
</svg>
|
|
<span class="text-muted">Ensure credentials have <strong>write permissions</strong> on target bucket.</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if can_edit_policy %}
|
|
<div class="tab-pane fade {{ 'show active' if active_tab == 'lifecycle' else '' }}" id="lifecycle-pane" role="tabpanel" aria-labelledby="lifecycle-tab" tabindex="0">
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm" id="lifecycle-rules-card" data-lifecycle-url="{{ lifecycle_url }}">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Lifecycle Rules</span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#addLifecycleRuleModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
|
</svg>
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-3">Lifecycle rules automatically delete objects or abort multipart uploads after a specified number of days.</p>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Prefix</th>
|
|
<th>Status</th>
|
|
<th>Expiration</th>
|
|
<th>Noncurrent</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="lifecycle-rules-body">
|
|
<tr>
|
|
<td colspan="6" class="text-center text-muted py-4">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mt-4" id="lifecycle-history-card"
|
|
data-history-endpoint="{{ url_for('ui.get_lifecycle_history', bucket_name=bucket_name) }}">
|
|
<div class="card-header d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
|
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
|
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
|
</svg>
|
|
<span class="fw-semibold">Execution History</span>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-3">Lifecycle rules are evaluated automatically (default: every hour). Recent executions are shown below.</p>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Executed</th>
|
|
<th class="text-center">Deleted</th>
|
|
<th class="text-center">Versions</th>
|
|
<th class="text-center">Aborted</th>
|
|
<th class="text-center">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="lifecycle-history-body">
|
|
<tr>
|
|
<td colspan="5" class="text-center text-muted py-4">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="mt-2" id="lifecycle-history-pagination" style="display: none;">
|
|
<button class="btn btn-link btn-sm p-0" id="show-more-history">Show more...</button>
|
|
<span class="text-muted small ms-2" id="history-shown-count"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="card bg-body-tertiary border-0 mb-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
What are Lifecycle Rules?
|
|
</h6>
|
|
<p class="small text-muted mb-3">Lifecycle rules automate object management by scheduling actions based on object age. This helps reduce storage costs and manage data retention automatically.</p>
|
|
<div class="alert alert-info small py-2 px-3 mb-3">
|
|
<strong>Tip:</strong> Use lifecycle rules to automatically clean up temporary files, logs, or expired content.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card bg-body-tertiary border-0">
|
|
<div class="card-body">
|
|
<h6 class="small fw-semibold mb-3">Available Actions</h6>
|
|
<div class="d-flex align-items-start mb-3">
|
|
<div class="bg-danger bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Expiration</div>
|
|
<div class="text-muted small">Delete current version objects after N days from creation</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-start mb-3">
|
|
<div class="bg-warning bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
|
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
|
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
|
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Noncurrent Expiration</div>
|
|
<div class="text-muted small">Delete old versions N days after becoming noncurrent (requires versioning)</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-start">
|
|
<div class="bg-secondary bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-secondary" viewBox="0 0 16 16">
|
|
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1H2.5zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5zM8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5zm3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Abort Multipart</div>
|
|
<div class="text-muted small">Clean up incomplete multipart uploads after N days</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-pane fade {{ 'show active' if active_tab == 'cors' else '' }}" id="cors-pane" role="tabpanel" aria-labelledby="cors-tab" tabindex="0">
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm" id="cors-rules-card" data-cors-url="{{ cors_url }}">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5z"/>
|
|
</svg>
|
|
<span class="fw-semibold">CORS Configuration</span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#addCorsRuleModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
|
</svg>
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-3">CORS rules define which external websites can access your bucket. Required for web apps making direct browser requests.</p>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Origins</th>
|
|
<th>Methods</th>
|
|
<th>Headers</th>
|
|
<th>Max Age</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="cors-rules-body">
|
|
<tr>
|
|
<td colspan="5" class="text-center text-muted py-4">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="card bg-body-tertiary border-0 mb-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
|
</svg>
|
|
What is CORS?
|
|
</h6>
|
|
<p class="small text-muted mb-3">CORS (Cross-Origin Resource Sharing) is a browser security feature that controls which websites can access your bucket data via JavaScript. Without CORS rules, browsers block cross-origin requests.</p>
|
|
<div class="alert alert-warning small py-2 px-3 mb-0">
|
|
<strong>When needed:</strong> Configure CORS if your web app fetches files directly from this bucket (e.g., loading images, JSON, or downloading files via JavaScript).
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card bg-body-tertiary border-0 mb-3">
|
|
<div class="card-body">
|
|
<h6 class="small fw-semibold mb-3">Configuration Fields</h6>
|
|
<div class="d-flex align-items-start mb-3">
|
|
<div class="bg-primary bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Allowed Origins</div>
|
|
<div class="text-muted small">Domains that can make requests (e.g., <code>https://myapp.com</code> or <code>*</code> for all)</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-start mb-3">
|
|
<div class="bg-success bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16">
|
|
<path d="M14.5 3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h13zm-13-1A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-13z"/>
|
|
<path d="M3 5.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3 8a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9A.5.5 0 0 1 3 8zm0 2.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Allowed Methods</div>
|
|
<div class="text-muted small">HTTP methods permitted (GET, PUT, POST, DELETE, HEAD)</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-start mb-3">
|
|
<div class="bg-info bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-info" viewBox="0 0 16 16">
|
|
<path d="M4 11a1 1 0 1 1 2 0v1a1 1 0 1 1-2 0v-1zm6-4a1 1 0 1 1 2 0v5a1 1 0 1 1-2 0V7zM7 9a1 1 0 0 1 2 0v3a1 1 0 1 1-2 0V9z"/>
|
|
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
|
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Allowed Headers</div>
|
|
<div class="text-muted small">Request headers the browser may send (e.g., <code>Content-Type</code>, <code>Authorization</code>)</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-start">
|
|
<div class="bg-secondary bg-opacity-10 rounded p-2 me-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-secondary" viewBox="0 0 16 16">
|
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="fw-semibold small">Max Age (seconds)</div>
|
|
<div class="text-muted small">How long browsers cache preflight responses (default: 0)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card bg-body-tertiary border-0">
|
|
<div class="card-body">
|
|
<h6 class="small fw-semibold mb-2">Quick Examples</h6>
|
|
<div class="small text-muted">
|
|
<p class="mb-2"><strong>Allow all origins:</strong></p>
|
|
<code class="d-block bg-body rounded p-2 mb-2">Origins: *<br>Methods: GET, HEAD</code>
|
|
<p class="mb-2"><strong>Specific domain:</strong></p>
|
|
<code class="d-block bg-body rounded p-2">Origins: https://myapp.com<br>Methods: GET, PUT, DELETE</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="deletePolicyModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold text-danger">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete bucket policy
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Warning:</strong> Removing the bucket policy will revert access control to IAM-only. Users relying on policy-based permissions may lose access.
|
|
</div>
|
|
</div>
|
|
<p class="text-muted small mb-0">Are you sure you want to delete the bucket policy for <strong>{{ bucket_name }}</strong>?</p>
|
|
</div>
|
|
<div class="modal-footer border-0 pt-0">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<form method="post" action="{{ url_for('ui.update_bucket_policy', bucket_name=bucket_name) }}" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="mode" value="delete" />
|
|
<button type="submit" class="btn btn-danger">Delete Policy</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="uploadModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
|
</svg>
|
|
Upload objects
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form
|
|
method="post"
|
|
action="{{ url_for('ui.upload_object', bucket_name=bucket_name) }}"
|
|
enctype="multipart/form-data"
|
|
data-upload-form
|
|
data-multipart-init-url="{{ url_for('ui.initiate_multipart_upload', bucket_name=bucket_name) }}"
|
|
data-multipart-part-template="{{ url_for('ui.upload_multipart_part', bucket_name=bucket_name, upload_id='UPLOAD_ID_PLACEHOLDER') }}"
|
|
data-multipart-complete-template="{{ url_for('ui.complete_multipart_upload', bucket_name=bucket_name, upload_id='UPLOAD_ID_PLACEHOLDER') }}"
|
|
data-multipart-abort-template="{{ url_for('ui.abort_multipart_upload', bucket_name=bucket_name, upload_id='UPLOAD_ID_PLACEHOLDER') }}"
|
|
>
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<div class="modal-body">
|
|
<p class="text-muted small mb-3">Upload files to <code>{{ bucket_name }}</code>. You can select multiple files at once.</p>
|
|
<div class="row g-3">
|
|
<div class="col-12">
|
|
<label class="form-label fw-medium">Select files</label>
|
|
<input class="form-control" type="file" name="object" id="uploadFileInput" multiple required />
|
|
<div class="form-text">Select one or more files from your device. Files ≥ 8 MB automatically switch to multipart uploads.</div>
|
|
</div>
|
|
<div class="col-12">
|
|
<div class="upload-dropzone text-center" data-dropzone>
|
|
<p class="fw-semibold mb-1">Drag & drop files here</p>
|
|
<p class="text-muted small mb-2">or click to browse (multiple files supported)</p>
|
|
<div class="text-muted small" data-dropzone-label>No files selected</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12">
|
|
<div class="border rounded-3">
|
|
<button
|
|
class="btn btn-link text-decoration-none w-100 text-start collapse-toggle"
|
|
type="button"
|
|
data-bs-toggle="collapse"
|
|
data-bs-target="#uploadAdvancedOptions"
|
|
aria-expanded="false"
|
|
aria-controls="uploadAdvancedOptions"
|
|
>
|
|
<span class="fw-semibold">Advanced options</span>
|
|
<span class="text-muted">(optional)</span>
|
|
</button>
|
|
<div class="collapse" id="uploadAdvancedOptions">
|
|
<div class="p-3 border-top">
|
|
<div id="singleFileOptions">
|
|
<label class="form-label fw-medium">Object key</label>
|
|
<input class="form-control font-monospace" type="text" name="object_key" placeholder="folder/document.pdf" />
|
|
<div class="form-text mb-3">Leave blank to reuse the original filename. (Only applies when uploading a single file)</div>
|
|
</div>
|
|
<label class="form-label fw-medium">Key prefix <span class="text-muted fw-normal">(optional)</span></label>
|
|
<input class="form-control font-monospace" type="text" name="key_prefix" id="uploadKeyPrefix" placeholder="uploads/2024/" />
|
|
<div class="form-text mb-3">Add a prefix to all uploaded files (e.g., <code>folder/subfolder/</code>).</div>
|
|
<label class="form-label fw-medium">Metadata <span class="text-muted fw-normal">(JSON)</span></label>
|
|
<textarea class="form-control font-monospace" name="metadata" rows="3" placeholder='{"project":"demo"}'></textarea>
|
|
<div class="form-text">Store custom key/value pairs alongside each object.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12">
|
|
<div class="upload-progress-stack" data-upload-progress></div>
|
|
</div>
|
|
<div class="col-12 d-none" id="uploadQueueContainer">
|
|
<div class="border rounded p-2" style="max-height: 150px; overflow-y: auto;">
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<span class="small fw-medium text-muted">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.5 0 0 1 2 15.5V2zm2-1a1 1 0 0 0-1 1v12.566l4.723-2.482a.5.5 0 0 1 .554 0L13 14.566V2a1 1 0 0 0-1-1H4z"/>
|
|
</svg>
|
|
Queued files
|
|
</span>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="badge bg-secondary-subtle text-secondary" id="uploadQueueCount">0</span>
|
|
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1" id="clearUploadQueueBtn" title="Clear queue">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ul class="list-unstyled mb-0 small" id="uploadQueueList"></ul>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 d-none" id="bulkUploadProgress">
|
|
<div class="alert alert-info small mb-0">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span id="bulkUploadStatus">Uploading files...</span>
|
|
<span id="bulkUploadCounter">0/0</span>
|
|
</div>
|
|
<div class="progress" style="height: 8px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" id="bulkUploadProgressBar" role="progressbar" style="width: 0%;"></div>
|
|
</div>
|
|
<div id="bulkUploadCurrentFile" class="mt-2 text-muted small"></div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 d-none" id="bulkUploadResults">
|
|
<div class="alert alert-success small mb-2" id="bulkUploadSuccessAlert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
<span id="bulkUploadSuccessCount">0</span> file(s) uploaded successfully
|
|
</div>
|
|
<div class="alert alert-danger small mb-0 d-none" id="bulkUploadErrorAlert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
|
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
|
</svg>
|
|
<span id="bulkUploadErrorCount">0</span> file(s) failed to upload
|
|
<ul class="mb-0 mt-1" id="bulkUploadErrorList"></ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer border-0">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="uploadCancelBtn">Cancel</button>
|
|
<button class="btn btn-primary" type="submit" id="uploadSubmitBtn">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
|
</svg>
|
|
<span id="uploadBtnText">Upload</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="floatingUploadProgress" class="floating-upload-progress d-none">
|
|
<div class="floating-upload-content">
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<div class="d-flex align-items-center">
|
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
|
<span class="fw-semibold" id="floatingUploadTitle">Uploading files...</span>
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="floatingUploadExpand" title="Show upload modal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="progress mb-1" style="height: 6px;">
|
|
<div class="progress-bar bg-primary" id="floatingUploadProgressBar" role="progressbar" style="width: 0%;"></div>
|
|
</div>
|
|
<div class="text-muted small" id="floatingUploadStatus">Preparing...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="deleteBucketModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete bucket
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form method="post" action="{{ url_for('ui.delete_bucket', bucket_name=bucket_name) }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<div class="modal-body">
|
|
<div class="alert alert-danger d-flex align-items-center mb-3" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
|
|
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
|
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
|
</svg>
|
|
<div><strong>Warning:</strong> This action cannot be undone.</div>
|
|
</div>
|
|
<p class="mb-0">
|
|
Are you sure you want to delete <code class="fs-6">{{ bucket_name }}</code>?
|
|
<span class="text-muted">The bucket must be empty.</span>
|
|
</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button class="btn btn-danger" type="submit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete bucket
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="messageModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold" id="messageModalTitle">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16" id="messageModalIcon">
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
</svg>
|
|
Notice
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="messageModalBody"></div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
|
<button type="button" class="btn btn-primary d-none" id="messageModalAction"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="bulkDeleteModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete selected objects
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-danger d-flex align-items-start mb-3" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
|
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
|
</svg>
|
|
<div>
|
|
<p id="bulkDeleteCount" class="fw-semibold mb-1"></p>
|
|
<p class="mb-0 small">Deleted items cannot be recovered.</p>
|
|
</div>
|
|
</div>
|
|
<ul class="list-group mb-3" id="bulkDeleteList" style="max-height: 200px; overflow-y: auto;"></ul>
|
|
<div class="text-muted small" id="bulkDeleteStatus"></div>
|
|
{% if versioning_enabled %}
|
|
<div class="form-check mt-3 p-3 bg-body-tertiary rounded-3">
|
|
<input class="form-check-input" type="checkbox" id="bulkDeletePurge" />
|
|
<label class="form-check-label" for="bulkDeletePurge">Also delete archived versions</label>
|
|
<div class="form-text">Removes any archived versions stored in the archive.</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-danger" id="bulkDeleteConfirm" disabled>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete objects
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="deleteObjectModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete object
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form method="post" id="deleteObjectForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<div class="modal-body">
|
|
<p class="mb-3">
|
|
Are you sure you want to delete this object?
|
|
</p>
|
|
<div class="p-3 bg-body-tertiary rounded-3 mb-3">
|
|
<code id="deleteObjectKey" class="d-block text-break"></code>
|
|
</div>
|
|
{% if versioning_enabled %}
|
|
<div class="alert alert-warning d-flex align-items-start small mb-3" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
</svg>
|
|
<div>Versioning is enabled. Deleting normally keeps previous versions in the archive.</div>
|
|
</div>
|
|
<div class="form-check p-3 bg-body-tertiary rounded-3">
|
|
<input class="form-check-input" type="checkbox" value="1" id="deletePurgeVersions" name="purge_versions" />
|
|
<label class="form-check-label" for="deletePurgeVersions">Also delete all archived versions</label>
|
|
<div class="form-text mb-0">Removes the live object and every stored version.</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button class="btn btn-danger" type="submit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="presignModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
|
</svg>
|
|
Presigned URL
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted small mb-3">Generate a temporary signed URL to share access to this object.</p>
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">HTTP method</label>
|
|
<select class="form-select" id="presignMethod">
|
|
<option value="GET">GET (download)</option>
|
|
<option value="PUT">PUT (upload)</option>
|
|
<option value="DELETE">DELETE</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">Expires (seconds)</label>
|
|
<input class="form-control" type="number" id="presignTtl" value="900" min="1" max="604800" />
|
|
<div class="form-text">Max: 604800 (7 days)</div>
|
|
</div>
|
|
</div>
|
|
<label class="form-label fw-medium mt-3">Signed URL</label>
|
|
<div class="input-group">
|
|
<input id="presignLink" class="form-control font-monospace bg-body-tertiary" type="text" readonly />
|
|
<button class="btn btn-outline-primary" type="button" id="copyPresignLink">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
|
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-primary" id="generatePresignButton">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
Generate
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="suspendVersioningModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h5 class="modal-title">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
Suspend Versioning
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning d-flex align-items-start mb-3" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Important:</strong> Suspending versioning will stop creating new versions for uploaded objects, but <strong>existing versioned objects will remain stored</strong> and continue to consume storage space.
|
|
</div>
|
|
</div>
|
|
<p class="text-muted small mb-0">To permanently remove old versions, you must re-enable versioning and manually delete them, or configure a lifecycle rule to expire noncurrent versions.</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<form method="POST" action="{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="state" value="suspend" />
|
|
<button type="submit" class="btn btn-warning">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
|
</svg>
|
|
Suspend Versioning
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="disableReplicationModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h5 class="modal-title fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
|
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
|
|
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
|
|
</svg>
|
|
Disable Replication
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning d-flex align-items-start mb-3" role="alert">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
|
|
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
|
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
|
</svg>
|
|
<div>
|
|
<strong>Are you sure?</strong><br>
|
|
<span class="small">New uploads will no longer be copied to the target bucket.</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-muted small mb-0">Existing objects in the target bucket will remain unchanged.</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
|
<input type="hidden" name="action" value="delete">
|
|
<button type="submit" class="btn btn-warning">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
</svg>
|
|
Disable Replication
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="addLifecycleRuleModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
|
</svg>
|
|
Add Lifecycle Rule
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">Rule ID</label>
|
|
<input type="text" class="form-control" id="lifecycleRuleId" placeholder="cleanup-old-files" required />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">Status</label>
|
|
<select class="form-select" id="lifecycleRuleStatus">
|
|
<option value="Enabled">Enabled</option>
|
|
<option value="Disabled">Disabled</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label fw-medium">Prefix Filter <span class="text-muted fw-normal">(optional)</span></label>
|
|
<input type="text" class="form-control font-monospace" id="lifecycleRulePrefix" placeholder="logs/" />
|
|
<div class="form-text">Only apply this rule to objects with keys starting with this prefix.</div>
|
|
</div>
|
|
<div class="col-12">
|
|
<hr class="my-2">
|
|
<h6 class="fw-semibold mb-3">Actions</h6>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label fw-medium">Expiration (days)</label>
|
|
<input type="number" class="form-control" id="lifecycleExpirationDays" min="1" placeholder="30" />
|
|
<div class="form-text">Delete objects after N days.</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label fw-medium">Noncurrent Expiration</label>
|
|
<input type="number" class="form-control" id="lifecycleNoncurrentDays" min="1" placeholder="90" />
|
|
<div class="form-text">Delete old versions after N days.</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label fw-medium">Abort Incomplete MPU</label>
|
|
<input type="number" class="form-control" id="lifecycleAbortMpuDays" min="1" placeholder="7" />
|
|
<div class="form-text">Abort uploads after N days.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer border-0 pt-0">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="addLifecycleRuleConfirm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
|
</svg>
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="addCorsRuleModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5z"/>
|
|
</svg>
|
|
Add CORS Rule
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-3">
|
|
<div class="col-12">
|
|
<label class="form-label fw-medium">Allowed Origins</label>
|
|
<textarea class="form-control font-monospace" id="corsAllowedOrigins" rows="3" placeholder="* https://example.com http://localhost:3000"></textarea>
|
|
<div class="form-text">One origin per line. Use <code>*</code> for all origins.</div>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label fw-medium">Allowed Methods</label>
|
|
<div class="d-flex flex-wrap gap-3">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="corsMethodGet" value="GET" checked>
|
|
<label class="form-check-label" for="corsMethodGet">GET</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="corsMethodPut" value="PUT">
|
|
<label class="form-check-label" for="corsMethodPut">PUT</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="corsMethodPost" value="POST">
|
|
<label class="form-check-label" for="corsMethodPost">POST</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="corsMethodDelete" value="DELETE">
|
|
<label class="form-check-label" for="corsMethodDelete">DELETE</label>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="corsMethodHead" value="HEAD" checked>
|
|
<label class="form-check-label" for="corsMethodHead">HEAD</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">Allowed Headers <span class="text-muted fw-normal">(optional)</span></label>
|
|
<textarea class="form-control font-monospace" id="corsAllowedHeaders" rows="2" placeholder="* Content-Type"></textarea>
|
|
<div class="form-text">One header per line.</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">Expose Headers <span class="text-muted fw-normal">(optional)</span></label>
|
|
<textarea class="form-control font-monospace" id="corsExposeHeaders" rows="2" placeholder="ETag x-amz-meta-*"></textarea>
|
|
<div class="form-text">One header per line.</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label fw-medium">Max Age (seconds) <span class="text-muted fw-normal">(optional)</span></label>
|
|
<input type="number" class="form-control" id="corsMaxAge" min="0" placeholder="3600" />
|
|
<div class="form-text">How long browsers cache preflight responses.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer border-0 pt-0">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="addCorsRuleConfirm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
|
</svg>
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="copyMoveModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header border-0 pb-0">
|
|
<h1 class="modal-title fs-5 fw-semibold" id="copyMoveModalTitle">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16" id="copyMoveModalIcon">
|
|
<path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2Zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6ZM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2Z"/>
|
|
</svg>
|
|
<span id="copyMoveActionLabel">Copy</span> Object
|
|
</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label fw-medium">Source</label>
|
|
<div class="p-2 bg-body-tertiary rounded font-monospace small" id="copyMoveSource"></div>
|
|
</div>
|
|
<div class="row g-3">
|
|
<div class="col-12">
|
|
<label class="form-label fw-medium">Destination Bucket</label>
|
|
<select class="form-select" id="copyMoveDestBucket">
|
|
<option value="">Loading buckets...</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label fw-medium">Destination Key</label>
|
|
<input type="text" class="form-control font-monospace" id="copyMoveDestKey" placeholder="path/to/object.txt" required />
|
|
<div class="form-text">The full path for the object in the destination bucket.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer border-0 pt-0">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="copyMoveConfirm">
|
|
<span id="copyMoveConfirmLabel">Copy</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script src="{{ url_for('static', filename='js/bucket-detail-utils.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/bucket-detail-upload.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/bucket-detail-operations.js') }}"></script>
|
|
<script>
|
|
const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
|
|
formatBytes: (bytes) => {
|
|
if (!Number.isFinite(bytes)) return `${bytes} bytes`;
|
|
const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
let size = bytes;
|
|
while (size >= 1024 && i < units.length - 1) {
|
|
size /= 1024;
|
|
i++;
|
|
}
|
|
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
},
|
|
escapeHtml: (value) => {
|
|
if (value === null || value === undefined) return '';
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
},
|
|
fallbackCopy: () => false,
|
|
setupJsonAutoIndent: () => {}
|
|
};
|
|
|
|
setupJsonAutoIndent(document.getElementById('policyDocument'));
|
|
|
|
const selectAllCheckbox = document.querySelector('[data-select-all]');
|
|
const bulkDeleteButton = document.querySelector('[data-bulk-delete-trigger]');
|
|
const bulkDeleteLabel = bulkDeleteButton?.querySelector('[data-bulk-delete-label]');
|
|
const bulkDeleteModalEl = document.getElementById('bulkDeleteModal');
|
|
const bulkDeleteModal = bulkDeleteModalEl ? new bootstrap.Modal(bulkDeleteModalEl) : null;
|
|
const bulkDeleteList = document.getElementById('bulkDeleteList');
|
|
const bulkDeleteCount = document.getElementById('bulkDeleteCount');
|
|
const bulkDeleteStatus = document.getElementById('bulkDeleteStatus');
|
|
const bulkDeleteConfirm = document.getElementById('bulkDeleteConfirm');
|
|
const bulkDeletePurge = document.getElementById('bulkDeletePurge');
|
|
const previewPanel = document.getElementById('preview-panel');
|
|
const previewEmpty = document.getElementById('preview-empty');
|
|
const previewKey = document.getElementById('preview-key');
|
|
const previewSize = document.getElementById('preview-size');
|
|
const previewModified = document.getElementById('preview-modified');
|
|
const previewEtag = document.getElementById('preview-etag');
|
|
const previewMetadata = document.getElementById('preview-metadata');
|
|
const previewMetadataList = document.getElementById('preview-metadata-list');
|
|
const previewPlaceholder = document.getElementById('preview-placeholder');
|
|
const previewImage = document.getElementById('preview-image');
|
|
const previewVideo = document.getElementById('preview-video');
|
|
const previewIframe = document.getElementById('preview-iframe');
|
|
const downloadButton = document.getElementById('downloadButton');
|
|
const presignButton = document.getElementById('presignButton');
|
|
const presignModalEl = document.getElementById('presignModal');
|
|
const presignModal = presignModalEl ? new bootstrap.Modal(presignModalEl) : null;
|
|
const presignMethod = document.getElementById('presignMethod');
|
|
const presignTtl = document.getElementById('presignTtl');
|
|
const presignLink = document.getElementById('presignLink');
|
|
const copyPresignLink = document.getElementById('copyPresignLink');
|
|
const copyPresignDefaultLabel = copyPresignLink?.textContent?.trim() || 'Copy';
|
|
const generatePresignButton = document.getElementById('generatePresignButton');
|
|
const policyForm = document.getElementById('bucketPolicyForm');
|
|
const policyTextarea = document.getElementById('policyDocument');
|
|
const policyPreset = document.getElementById('policyPreset');
|
|
const policyMode = document.getElementById('policyMode');
|
|
const uploadForm = document.querySelector('[data-upload-form]');
|
|
const uploadModalEl = document.getElementById('uploadModal');
|
|
const uploadModal = uploadModalEl ? bootstrap.Modal.getOrCreateInstance(uploadModalEl) : null;
|
|
const uploadFileInput = uploadForm?.querySelector('input[name="object"]');
|
|
const uploadDropZone = uploadForm?.querySelector('[data-dropzone]');
|
|
const uploadDropZoneLabel = uploadDropZone?.querySelector('[data-dropzone-label]');
|
|
const messageModalEl = document.getElementById('messageModal');
|
|
const messageModal = messageModalEl ? new bootstrap.Modal(messageModalEl) : null;
|
|
const messageModalTitle = document.getElementById('messageModalTitle');
|
|
const messageModalBody = document.getElementById('messageModalBody');
|
|
const messageModalAction = document.getElementById('messageModalAction');
|
|
let messageModalActionHandler = null;
|
|
let isGeneratingPresign = false;
|
|
const objectsContainer = document.querySelector('.objects-table-container[data-bucket]');
|
|
const bulkDeleteEndpoint = objectsContainer?.dataset.bulkDeleteEndpoint || '';
|
|
const objectsApiUrl = objectsContainer?.dataset.objectsApi || '';
|
|
const versionPanel = document.getElementById('version-panel');
|
|
const versionList = document.getElementById('version-list');
|
|
const refreshVersionsButton = document.getElementById('refreshVersionsButton');
|
|
const archivedCard = document.getElementById('archived-objects-card');
|
|
const archivedBody = archivedCard?.querySelector('[data-archived-body]');
|
|
const archivedCountBadge = archivedCard?.querySelector('[data-archived-count]');
|
|
const archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]');
|
|
const archivedEndpoint = archivedCard?.dataset.archivedEndpoint;
|
|
let versioningEnabled = objectsContainer?.dataset.versioning === 'true';
|
|
const versionsCache = new Map();
|
|
let activeRow = null;
|
|
const selectedRows = new Map();
|
|
let bulkDeleting = false;
|
|
if (presignButton) presignButton.disabled = true;
|
|
if (generatePresignButton) generatePresignButton.disabled = true;
|
|
if (downloadButton) downloadButton.classList.add('disabled');
|
|
|
|
const objectCountBadge = document.getElementById('object-count-badge');
|
|
const loadMoreContainer = document.getElementById('load-more-container');
|
|
const loadMoreSpinner = document.getElementById('load-more-spinner');
|
|
const loadMoreStatus = document.getElementById('load-more-status');
|
|
const objectsLoadingRow = document.getElementById('objects-loading-row');
|
|
let nextContinuationToken = null;
|
|
let totalObjectCount = 0;
|
|
let loadedObjectCount = 0;
|
|
let isLoadingObjects = false;
|
|
let hasMoreObjects = false;
|
|
let currentFilterTerm = '';
|
|
let pageSize = 5000;
|
|
let currentPrefix = '';
|
|
let allObjects = [];
|
|
let urlTemplates = null;
|
|
|
|
const buildUrlFromTemplate = (template, key) => {
|
|
if (!template) return '';
|
|
return template.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/'));
|
|
};
|
|
|
|
const ROW_HEIGHT = 53;
|
|
const BUFFER_ROWS = 10;
|
|
let visibleItems = [];
|
|
let renderedRange = { start: 0, end: 0 };
|
|
|
|
const createObjectRow = (obj, displayKey = null) => {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.objectRow = '';
|
|
tr.dataset.key = obj.key;
|
|
tr.dataset.size = obj.size;
|
|
tr.dataset.lastModified = obj.lastModified || obj.last_modified;
|
|
tr.dataset.etag = obj.etag;
|
|
tr.dataset.previewUrl = obj.previewUrl || obj.preview_url;
|
|
tr.dataset.downloadUrl = obj.downloadUrl || obj.download_url;
|
|
tr.dataset.presignEndpoint = obj.presignEndpoint || obj.presign_endpoint;
|
|
tr.dataset.deleteEndpoint = obj.deleteEndpoint || obj.delete_endpoint;
|
|
tr.dataset.metadata = typeof obj.metadata === 'string' ? obj.metadata : JSON.stringify(obj.metadata || {});
|
|
tr.dataset.versionsEndpoint = obj.versionsEndpoint || obj.versions_endpoint;
|
|
tr.dataset.restoreTemplate = obj.restoreTemplate || obj.restore_template;
|
|
tr.dataset.tagsUrl = obj.tagsUrl || obj.tags_url;
|
|
tr.dataset.copyUrl = obj.copyUrl || obj.copy_url;
|
|
tr.dataset.moveUrl = obj.moveUrl || obj.move_url;
|
|
|
|
const keyToShow = displayKey || obj.key;
|
|
const lastModDisplay = obj.lastModifiedDisplay || obj.last_modified_display || new Date(obj.lastModified || obj.last_modified).toLocaleDateString();
|
|
|
|
tr.innerHTML = `
|
|
<td class="text-center align-middle">
|
|
<input class="form-check-input" type="checkbox" data-object-select aria-label="Select ${escapeHtml(obj.key)}" />
|
|
</td>
|
|
<td class="object-key text-break" title="${escapeHtml(obj.key)}">
|
|
<div class="fw-medium">${escapeHtml(keyToShow)}</div>
|
|
<div class="text-muted small">Modified ${escapeHtml(lastModDisplay)}</div>
|
|
</td>
|
|
<td class="text-end text-nowrap">
|
|
<span class="text-muted small">${formatBytes(obj.size)}</span>
|
|
</td>
|
|
<td class="text-end">
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
<a
|
|
class="btn btn-outline-primary btn-icon"
|
|
href="${escapeHtml(obj.downloadUrl || obj.download_url)}"
|
|
target="_blank"
|
|
title="Download"
|
|
aria-label="Download"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="#0d6efd" class="bi bi-download" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z" />
|
|
</svg>
|
|
</a>
|
|
<div class="dropdown d-inline-block">
|
|
<button class="btn btn-outline-secondary btn-icon dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" title="More actions">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
|
|
</svg>
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end" style="position: fixed;">
|
|
<li><button class="dropdown-item" type="button" onclick="openCopyMoveModal('copy', '${escapeHtml(obj.key)}')">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2Zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6ZM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2Z"/></svg>
|
|
Copy
|
|
</button></li>
|
|
<li><button class="dropdown-item" type="button" onclick="openCopyMoveModal('move', '${escapeHtml(obj.key)}')">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/></svg>
|
|
Move
|
|
</button></li>
|
|
<li><hr class="dropdown-divider"></li>
|
|
<li><button class="dropdown-item text-danger" type="button" data-delete-object>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>
|
|
Delete
|
|
</button></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
return tr;
|
|
};
|
|
|
|
const showEmptyState = () => {
|
|
if (!objectsTableBody) return;
|
|
objectsTableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="4" class="py-5">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon mx-auto" style="width: 64px; height: 64px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
|
</svg>
|
|
</div>
|
|
<h6 class="mb-2">No objects yet</h6>
|
|
<p class="text-muted small mb-3">Drag and drop files here or click Upload to get started.</p>
|
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
|
</svg>
|
|
Upload Files
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
};
|
|
|
|
const showLoadError = (message) => {
|
|
if (!objectsTableBody) return;
|
|
objectsTableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="4" class="py-5">
|
|
<div class="text-center text-danger">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="mb-2" viewBox="0 0 16 16">
|
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
|
</svg>
|
|
<p class="mb-2">Failed to load objects</p>
|
|
<p class="small text-muted mb-3">${escapeHtml(message)}</p>
|
|
<button class="btn btn-outline-primary btn-sm" onclick="loadObjects()">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
};
|
|
|
|
const updateObjectCountBadge = () => {
|
|
if (!objectCountBadge) return;
|
|
if (totalObjectCount === 0) {
|
|
objectCountBadge.textContent = '0 objects';
|
|
} else {
|
|
objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`;
|
|
}
|
|
};
|
|
|
|
let topSpacer = null;
|
|
let bottomSpacer = null;
|
|
|
|
const initVirtualScrollElements = () => {
|
|
if (!objectsTableBody) return;
|
|
|
|
if (!topSpacer) {
|
|
topSpacer = document.createElement('tr');
|
|
topSpacer.id = 'virtual-top-spacer';
|
|
topSpacer.innerHTML = '<td colspan="4" style="padding: 0; border: none;"></td>';
|
|
}
|
|
if (!bottomSpacer) {
|
|
bottomSpacer = document.createElement('tr');
|
|
bottomSpacer.id = 'virtual-bottom-spacer';
|
|
bottomSpacer.innerHTML = '<td colspan="4" style="padding: 0; border: none;"></td>';
|
|
}
|
|
};
|
|
|
|
const computeVisibleItems = () => {
|
|
const items = [];
|
|
const folders = new Set();
|
|
|
|
allObjects.forEach(obj => {
|
|
if (!obj.key.startsWith(currentPrefix)) return;
|
|
|
|
const remainder = obj.key.slice(currentPrefix.length);
|
|
|
|
if (!remainder) return;
|
|
|
|
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
|
|
const slashIndex = remainder.indexOf('/');
|
|
|
|
if (slashIndex === -1 && !isFolderMarker) {
|
|
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
|
|
items.push({ type: 'file', data: obj, displayKey: remainder });
|
|
}
|
|
} else {
|
|
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
|
|
? slashIndex
|
|
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
|
|
const folderName = remainder.slice(0, effectiveSlashIndex);
|
|
const folderPath = currentPrefix + folderName + '/';
|
|
if (!folders.has(folderPath)) {
|
|
folders.add(folderPath);
|
|
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
|
|
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
items.sort((a, b) => {
|
|
if (a.type === 'folder' && b.type === 'file') return -1;
|
|
if (a.type === 'file' && b.type === 'folder') return 1;
|
|
const aKey = a.type === 'folder' ? a.path : a.data.key;
|
|
const bKey = b.type === 'folder' ? b.path : b.data.key;
|
|
return aKey.localeCompare(bKey);
|
|
});
|
|
|
|
return items;
|
|
};
|
|
|
|
const renderVirtualRows = () => {
|
|
if (!objectsTableBody || !scrollContainer) return;
|
|
|
|
const containerHeight = scrollContainer.clientHeight;
|
|
const scrollTop = scrollContainer.scrollTop;
|
|
|
|
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS);
|
|
const endIndex = Math.min(visibleItems.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER_ROWS);
|
|
|
|
if (startIndex === renderedRange.start && endIndex === renderedRange.end) return;
|
|
|
|
renderedRange = { start: startIndex, end: endIndex };
|
|
|
|
objectsTableBody.innerHTML = '';
|
|
|
|
initVirtualScrollElements();
|
|
topSpacer.querySelector('td').style.height = `${startIndex * ROW_HEIGHT}px`;
|
|
objectsTableBody.appendChild(topSpacer);
|
|
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const item = visibleItems[i];
|
|
if (!item) continue;
|
|
|
|
let row;
|
|
if (item.type === 'folder') {
|
|
row = createFolderRow(item.path, item.displayKey);
|
|
} else {
|
|
row = createObjectRow(item.data, item.displayKey);
|
|
}
|
|
row.dataset.virtualIndex = i;
|
|
objectsTableBody.appendChild(row);
|
|
}
|
|
|
|
const remainingRows = visibleItems.length - endIndex;
|
|
bottomSpacer.querySelector('td').style.height = `${remainingRows * ROW_HEIGHT}px`;
|
|
objectsTableBody.appendChild(bottomSpacer);
|
|
|
|
attachRowHandlers();
|
|
};
|
|
|
|
let scrollTimeout = null;
|
|
const handleVirtualScroll = () => {
|
|
if (scrollTimeout) cancelAnimationFrame(scrollTimeout);
|
|
scrollTimeout = requestAnimationFrame(renderVirtualRows);
|
|
};
|
|
|
|
const refreshVirtualList = () => {
|
|
visibleItems = computeVisibleItems();
|
|
renderedRange = { start: -1, end: -1 };
|
|
|
|
if (visibleItems.length === 0) {
|
|
if (allObjects.length === 0 && !hasMoreObjects) {
|
|
showEmptyState();
|
|
} else {
|
|
objectsTableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="4" class="py-5">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon mx-auto" style="width: 64px; height: 64px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
|
|
</svg>
|
|
</div>
|
|
<h6 class="mb-2">Empty folder</h6>
|
|
<p class="text-muted small mb-0">This folder contains no objects${hasMoreObjects ? ' yet. Loading more...' : '.'}</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
} else {
|
|
renderVirtualRows();
|
|
}
|
|
|
|
updateFolderViewStatus();
|
|
};
|
|
|
|
const updateFolderViewStatus = () => {
|
|
const folderViewStatusEl = document.getElementById('folder-view-status');
|
|
if (!folderViewStatusEl) return;
|
|
|
|
if (currentPrefix) {
|
|
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
|
|
const fileCount = visibleItems.filter(i => i.type === 'file').length;
|
|
folderViewStatusEl.innerHTML = `<span class="text-muted">${folderCount} folder${folderCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''} in this view</span>`;
|
|
folderViewStatusEl.classList.remove('d-none');
|
|
} else {
|
|
folderViewStatusEl.classList.add('d-none');
|
|
}
|
|
};
|
|
|
|
const loadObjects = async (append = false) => {
|
|
if (isLoadingObjects) return;
|
|
isLoadingObjects = true;
|
|
|
|
if (!append) {
|
|
if (objectsLoadingRow) objectsLoadingRow.style.display = '';
|
|
nextContinuationToken = null;
|
|
loadedObjectCount = 0;
|
|
allObjects = [];
|
|
}
|
|
|
|
if (append && loadMoreSpinner) {
|
|
loadMoreSpinner.classList.remove('d-none');
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams({ max_keys: String(pageSize) });
|
|
if (nextContinuationToken) {
|
|
params.set('continuation_token', nextContinuationToken);
|
|
}
|
|
|
|
const response = await fetch(`${objectsApiUrl}?${params}`);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
versioningEnabled = data.versioning_enabled;
|
|
if (objectsContainer) {
|
|
objectsContainer.dataset.versioning = versioningEnabled ? 'true' : 'false';
|
|
}
|
|
|
|
totalObjectCount = data.total_count || 0;
|
|
nextContinuationToken = data.next_continuation_token;
|
|
|
|
if (!append && objectsLoadingRow) {
|
|
objectsLoadingRow.remove();
|
|
}
|
|
|
|
if (data.url_templates && !urlTemplates) {
|
|
urlTemplates = data.url_templates;
|
|
}
|
|
|
|
data.objects.forEach(obj => {
|
|
loadedObjectCount++;
|
|
const key = obj.key;
|
|
allObjects.push({
|
|
key: key,
|
|
size: obj.size,
|
|
lastModified: obj.last_modified,
|
|
lastModifiedDisplay: obj.last_modified_display,
|
|
etag: obj.etag,
|
|
previewUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.preview, key) : '',
|
|
downloadUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.download, key) : '',
|
|
presignEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.presign, key) : '',
|
|
deleteEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.delete, key) : '',
|
|
metadata: '{}',
|
|
versionsEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.versions, key) : '',
|
|
restoreTemplate: urlTemplates ? urlTemplates.restore.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')) : '',
|
|
tagsUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.tags, key) : '',
|
|
copyUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.copy, key) : '',
|
|
moveUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.move, key) : ''
|
|
});
|
|
});
|
|
|
|
updateObjectCountBadge();
|
|
hasMoreObjects = data.is_truncated;
|
|
|
|
if (loadMoreStatus) {
|
|
if (data.is_truncated) {
|
|
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} of ${totalObjectCount.toLocaleString()} loaded`;
|
|
} else {
|
|
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
|
}
|
|
}
|
|
|
|
if (typeof updateLoadMoreButton === 'function') {
|
|
updateLoadMoreButton();
|
|
}
|
|
|
|
refreshVirtualList();
|
|
renderBreadcrumb(currentPrefix);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load objects:', error);
|
|
if (!append) {
|
|
showLoadError(error.message);
|
|
} else {
|
|
showMessage({ title: 'Load Failed', body: error.message, variant: 'danger' });
|
|
}
|
|
} finally {
|
|
isLoadingObjects = false;
|
|
if (loadMoreSpinner) {
|
|
loadMoreSpinner.classList.add('d-none');
|
|
}
|
|
}
|
|
};
|
|
|
|
const attachRowHandlers = () => {
|
|
const objectRows = document.querySelectorAll('[data-object-row]');
|
|
objectRows.forEach(row => {
|
|
if (row.dataset.handlersAttached) return;
|
|
row.dataset.handlersAttached = 'true';
|
|
|
|
const deleteBtn = row.querySelector('[data-delete-object]');
|
|
deleteBtn?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const deleteModalEl = document.getElementById('deleteObjectModal');
|
|
const deleteModal = deleteModalEl ? bootstrap.Modal.getOrCreateInstance(deleteModalEl) : null;
|
|
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
|
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
|
if (deleteModal && deleteObjectForm) {
|
|
deleteObjectForm.action = row.dataset.deleteEndpoint;
|
|
if (deleteObjectKey) deleteObjectKey.textContent = row.dataset.key;
|
|
deleteModal.show();
|
|
}
|
|
});
|
|
|
|
const selectCheckbox = row.querySelector('[data-object-select]');
|
|
selectCheckbox?.addEventListener('click', (event) => event.stopPropagation());
|
|
selectCheckbox?.addEventListener('change', () => {
|
|
toggleRowSelection(row, selectCheckbox.checked);
|
|
});
|
|
|
|
if (selectedRows.has(row.dataset.key)) {
|
|
selectCheckbox.checked = true;
|
|
row.classList.add('table-active');
|
|
}
|
|
});
|
|
|
|
const folderRows = document.querySelectorAll('.folder-row');
|
|
folderRows.forEach(row => {
|
|
if (row.dataset.handlersAttached) return;
|
|
row.dataset.handlersAttached = 'true';
|
|
|
|
const folderPath = row.dataset.folderPath;
|
|
|
|
const checkbox = row.querySelector('[data-folder-select]');
|
|
checkbox?.addEventListener('change', (e) => {
|
|
e.stopPropagation();
|
|
const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath));
|
|
folderObjects.forEach(obj => {
|
|
if (checkbox.checked) {
|
|
selectedRows.set(obj.key, obj);
|
|
} else {
|
|
selectedRows.delete(obj.key);
|
|
}
|
|
});
|
|
updateBulkDeleteState();
|
|
});
|
|
|
|
const folderBtn = row.querySelector('button');
|
|
folderBtn?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
navigateToFolder(folderPath);
|
|
});
|
|
|
|
row.addEventListener('click', (e) => {
|
|
if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return;
|
|
navigateToFolder(folderPath);
|
|
});
|
|
});
|
|
|
|
updateBulkDeleteState();
|
|
};
|
|
|
|
const scrollSentinel = document.getElementById('scroll-sentinel');
|
|
const scrollContainer = document.querySelector('.objects-table-container');
|
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
|
|
|
if (scrollContainer) {
|
|
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
|
}
|
|
|
|
loadMoreBtn?.addEventListener('click', () => {
|
|
if (hasMoreObjects && !isLoadingObjects) {
|
|
loadObjects(true);
|
|
}
|
|
});
|
|
|
|
function updateLoadMoreButton() {
|
|
if (loadMoreBtn) {
|
|
loadMoreBtn.classList.toggle('d-none', !hasMoreObjects);
|
|
}
|
|
}
|
|
|
|
if (scrollSentinel && scrollContainer) {
|
|
const containerObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
|
|
loadObjects(true);
|
|
}
|
|
});
|
|
}, {
|
|
root: scrollContainer,
|
|
rootMargin: '500px',
|
|
threshold: 0
|
|
});
|
|
containerObserver.observe(scrollSentinel);
|
|
|
|
const viewportObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
|
|
loadObjects(true);
|
|
}
|
|
});
|
|
}, {
|
|
root: null,
|
|
rootMargin: '500px',
|
|
threshold: 0
|
|
});
|
|
viewportObserver.observe(scrollSentinel);
|
|
}
|
|
|
|
const pageSizeSelect = document.getElementById('page-size-select');
|
|
pageSizeSelect?.addEventListener('change', (e) => {
|
|
pageSize = parseInt(e.target.value, 10);
|
|
});
|
|
|
|
if (objectsApiUrl) {
|
|
loadObjects();
|
|
}
|
|
|
|
const folderBreadcrumb = document.getElementById('folder-breadcrumb');
|
|
const objectsTableBody = document.querySelector('#objects-table tbody');
|
|
|
|
if (objectsTableBody) {
|
|
objectsTableBody.addEventListener('click', (e) => {
|
|
const row = e.target.closest('[data-object-row]');
|
|
if (!row) return;
|
|
|
|
if (e.target.closest('[data-delete-object]') || e.target.closest('[data-object-select]') || e.target.closest('a')) {
|
|
return;
|
|
}
|
|
|
|
selectRow(row);
|
|
});
|
|
}
|
|
|
|
const hasFolders = () => allObjects.some(obj => obj.key.includes('/'));
|
|
|
|
const getFoldersAtPrefix = (prefix) => {
|
|
const folders = new Set();
|
|
const files = [];
|
|
|
|
allObjects.forEach(obj => {
|
|
const key = obj.key;
|
|
if (!key.startsWith(prefix)) return;
|
|
|
|
const remainder = key.slice(prefix.length);
|
|
const slashIndex = remainder.indexOf('/');
|
|
|
|
if (slashIndex === -1) {
|
|
|
|
files.push(obj);
|
|
} else {
|
|
|
|
const folderName = remainder.slice(0, slashIndex + 1);
|
|
folders.add(prefix + folderName);
|
|
}
|
|
});
|
|
|
|
return { folders: Array.from(folders).sort(), files };
|
|
};
|
|
|
|
const countObjectsInFolder = (folderPrefix) => {
|
|
const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
|
|
return { count, mayHaveMore: hasMoreObjects };
|
|
};
|
|
|
|
const renderBreadcrumb = (prefix) => {
|
|
if (!folderBreadcrumb) return;
|
|
|
|
if (!prefix && !hasFolders()) {
|
|
folderBreadcrumb.classList.add('d-none');
|
|
return;
|
|
}
|
|
|
|
folderBreadcrumb.classList.remove('d-none');
|
|
const ol = folderBreadcrumb.querySelector('ol');
|
|
ol.innerHTML = '';
|
|
|
|
const rootLi = document.createElement('li');
|
|
rootLi.className = 'breadcrumb-item';
|
|
if (!prefix) {
|
|
rootLi.classList.add('active');
|
|
rootLi.setAttribute('aria-current', 'page');
|
|
rootLi.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z"/>
|
|
</svg>
|
|
Root
|
|
`;
|
|
} else {
|
|
rootLi.innerHTML = `
|
|
<a href="#" data-folder-nav="" class="text-decoration-none">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z"/>
|
|
</svg>
|
|
Root
|
|
</a>
|
|
`;
|
|
}
|
|
ol.appendChild(rootLi);
|
|
|
|
if (prefix) {
|
|
const parts = prefix.split('/').filter(Boolean);
|
|
let accumulated = '';
|
|
parts.forEach((part, index) => {
|
|
accumulated += part + '/';
|
|
const li = document.createElement('li');
|
|
li.className = 'breadcrumb-item';
|
|
|
|
if (index === parts.length - 1) {
|
|
li.classList.add('active');
|
|
li.setAttribute('aria-current', 'page');
|
|
li.textContent = part;
|
|
} else {
|
|
const a = document.createElement('a');
|
|
a.href = '#';
|
|
a.className = 'text-decoration-none';
|
|
a.dataset.folderNav = accumulated;
|
|
a.textContent = part;
|
|
li.appendChild(a);
|
|
}
|
|
ol.appendChild(li);
|
|
});
|
|
}
|
|
|
|
ol.querySelectorAll('[data-folder-nav]').forEach(link => {
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
navigateToFolder(link.dataset.folderNav);
|
|
});
|
|
});
|
|
};
|
|
|
|
const getObjectsInFolder = (folderPrefix) => {
|
|
return allObjects.filter(obj => obj.key.startsWith(folderPrefix));
|
|
};
|
|
|
|
const createFolderRow = (folderPath, displayName = null) => {
|
|
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
|
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
|
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'folder-row';
|
|
tr.dataset.folderPath = folderPath;
|
|
tr.style.cursor = 'pointer';
|
|
|
|
tr.innerHTML = `
|
|
<td class="text-center align-middle" onclick="event.stopPropagation();">
|
|
<input class="form-check-input" type="checkbox" data-folder-select="${escapeHtml(folderPath)}" aria-label="Select folder" />
|
|
</td>
|
|
<td class="object-key text-break">
|
|
<div class="fw-medium d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-warning flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
|
|
</svg>
|
|
<span>${escapeHtml(folderName)}/</span>
|
|
</div>
|
|
<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>
|
|
</td>
|
|
<td class="text-end text-nowrap">
|
|
<span class="text-muted small">—</span>
|
|
</td>
|
|
<td class="text-end">
|
|
<button type="button" class="btn btn-outline-primary btn-sm" title="Open folder">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
`;
|
|
|
|
return tr;
|
|
};
|
|
|
|
const navigateToFolder = (prefix) => {
|
|
currentPrefix = prefix;
|
|
|
|
if (scrollContainer) scrollContainer.scrollTop = 0;
|
|
|
|
refreshVirtualList();
|
|
renderBreadcrumb(prefix);
|
|
|
|
selectedRows.clear();
|
|
|
|
if (typeof updateBulkDeleteState === 'function') {
|
|
updateBulkDeleteState();
|
|
}
|
|
|
|
if (previewPanel) previewPanel.classList.add('d-none');
|
|
if (previewEmpty) previewEmpty.classList.remove('d-none');
|
|
activeRow = null;
|
|
};
|
|
|
|
const renderObjectsView = () => {
|
|
if (!objectsTableBody) return;
|
|
|
|
const { folders, files } = getFoldersAtPrefix(currentPrefix);
|
|
|
|
objectsTableBody.innerHTML = '';
|
|
|
|
folders.forEach(folderPath => {
|
|
objectsTableBody.appendChild(createFolderRow(folderPath));
|
|
});
|
|
|
|
files.forEach(obj => {
|
|
objectsTableBody.appendChild(obj.element);
|
|
obj.element.style.display = '';
|
|
|
|
const keyCell = obj.element.querySelector('.object-key .fw-medium');
|
|
if (keyCell && currentPrefix) {
|
|
const displayName = obj.key.slice(currentPrefix.length);
|
|
keyCell.textContent = displayName;
|
|
keyCell.closest('.object-key').title = obj.key;
|
|
} else if (keyCell) {
|
|
keyCell.textContent = obj.key;
|
|
}
|
|
});
|
|
|
|
allObjects.forEach(obj => {
|
|
if (!files.includes(obj)) {
|
|
obj.element.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
if (folders.length === 0 && files.length === 0) {
|
|
const emptyRow = document.createElement('tr');
|
|
emptyRow.innerHTML = `
|
|
<td colspan="4" class="py-5">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon mx-auto" style="width: 64px; height: 64px;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z"/>
|
|
</svg>
|
|
</div>
|
|
<h6 class="mb-2">Empty folder</h6>
|
|
<p class="text-muted small mb-0">This folder contains no objects.</p>
|
|
</div>
|
|
</td>
|
|
`;
|
|
objectsTableBody.appendChild(emptyRow);
|
|
}
|
|
|
|
if (typeof updateBulkDeleteState === 'function') {
|
|
updateBulkDeleteState();
|
|
}
|
|
};
|
|
|
|
const showMessage = ({ title = 'Notice', body = '', bodyHtml = null, variant = 'info', actionText = null, onAction = null }) => {
|
|
if (!messageModal) {
|
|
window.alert(body || title);
|
|
return;
|
|
}
|
|
document.querySelectorAll('.modal.show').forEach(modal => {
|
|
const instance = bootstrap.Modal.getInstance(modal);
|
|
if (instance && modal.id !== 'messageModal') {
|
|
instance.hide();
|
|
}
|
|
});
|
|
const iconEl = document.getElementById('messageModalIcon');
|
|
if (iconEl) {
|
|
const iconPaths = {
|
|
success: '<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>',
|
|
danger: '<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>',
|
|
warning: '<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>',
|
|
info: '<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>'
|
|
};
|
|
const iconColors = { success: 'text-success', danger: 'text-danger', warning: 'text-warning', info: 'text-primary' };
|
|
iconEl.innerHTML = iconPaths[variant] || iconPaths.info;
|
|
iconEl.classList.remove('text-success', 'text-danger', 'text-warning', 'text-primary');
|
|
iconEl.classList.add(iconColors[variant] || 'text-primary');
|
|
}
|
|
messageModalTitle.textContent = title;
|
|
if (bodyHtml) {
|
|
messageModalBody.innerHTML = bodyHtml;
|
|
} else {
|
|
messageModalBody.textContent = body;
|
|
}
|
|
messageModalActionHandler = null;
|
|
const variantClass = {
|
|
success: 'btn-success',
|
|
danger: 'btn-danger',
|
|
warning: 'btn-warning',
|
|
info: 'btn-primary',
|
|
};
|
|
Object.values(variantClass).forEach((cls) => messageModalAction.classList.remove(cls));
|
|
if (actionText && typeof onAction === 'function') {
|
|
messageModalAction.textContent = actionText;
|
|
messageModalAction.classList.remove('d-none');
|
|
messageModalAction.classList.add(variantClass[variant] || 'btn-primary');
|
|
messageModalActionHandler = onAction;
|
|
} else {
|
|
messageModalAction.classList.add('d-none');
|
|
}
|
|
setTimeout(() => messageModal.show(), 150);
|
|
};
|
|
|
|
messageModalAction?.addEventListener('click', () => {
|
|
if (typeof messageModalActionHandler === 'function') {
|
|
messageModalActionHandler();
|
|
}
|
|
messageModal?.hide();
|
|
});
|
|
|
|
messageModalEl?.addEventListener('hidden.bs.modal', () => {
|
|
messageModalActionHandler = null;
|
|
messageModalAction.classList.add('d-none');
|
|
});
|
|
|
|
const normalizePolicyTemplate = (rawTemplate) => {
|
|
if (!rawTemplate) {
|
|
return '';
|
|
}
|
|
try {
|
|
let parsed = JSON.parse(rawTemplate);
|
|
if (typeof parsed === 'string') {
|
|
parsed = JSON.parse(parsed);
|
|
}
|
|
return JSON.stringify(parsed, null, 2);
|
|
} catch {
|
|
return rawTemplate;
|
|
}
|
|
};
|
|
|
|
let publicPolicyTemplate = normalizePolicyTemplate(policyTextarea?.dataset.publicTemplate || '');
|
|
let customPolicyDraft = policyTextarea?.value || '';
|
|
|
|
const setPolicyTextareaState = (readonly) => {
|
|
if (!policyTextarea) return;
|
|
if (readonly) {
|
|
policyTextarea.setAttribute('readonly', 'readonly');
|
|
policyTextarea.classList.add('bg-body-secondary');
|
|
} else {
|
|
policyTextarea.removeAttribute('readonly');
|
|
policyTextarea.classList.remove('bg-body-secondary');
|
|
}
|
|
};
|
|
|
|
const policyReadonlyHint = document.getElementById('policyReadonlyHint');
|
|
|
|
const applyPolicyPreset = (preset) => {
|
|
if (!policyTextarea || !policyMode) return;
|
|
const isPresetMode = preset === 'private' || preset === 'public';
|
|
if (policyReadonlyHint) {
|
|
policyReadonlyHint.classList.toggle('d-none', !isPresetMode);
|
|
}
|
|
switch (preset) {
|
|
case 'private':
|
|
setPolicyTextareaState(true);
|
|
policyTextarea.value = '';
|
|
policyMode.value = 'delete';
|
|
break;
|
|
case 'public':
|
|
setPolicyTextareaState(true);
|
|
policyTextarea.value = publicPolicyTemplate || '';
|
|
policyMode.value = 'upsert';
|
|
break;
|
|
default:
|
|
setPolicyTextareaState(false);
|
|
policyTextarea.value = customPolicyDraft;
|
|
policyMode.value = 'upsert';
|
|
break;
|
|
}
|
|
};
|
|
|
|
policyTextarea?.addEventListener('input', () => {
|
|
if (policyPreset?.value === 'custom') {
|
|
customPolicyDraft = policyTextarea.value;
|
|
}
|
|
});
|
|
|
|
const presetButtons = document.querySelectorAll('.preset-btn[data-preset]');
|
|
presetButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const preset = btn.dataset.preset;
|
|
if (policyPreset) policyPreset.value = preset;
|
|
presetButtons.forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
applyPolicyPreset(preset);
|
|
});
|
|
});
|
|
|
|
if (policyPreset) {
|
|
applyPolicyPreset(policyPreset.value || policyPreset.dataset.default || 'custom');
|
|
}
|
|
|
|
policyForm?.addEventListener('submit', () => {
|
|
if (!policyMode || !policyPreset || !policyTextarea) {
|
|
return;
|
|
}
|
|
if (policyPreset.value === 'private') {
|
|
policyMode.value = 'delete';
|
|
policyTextarea.value = '';
|
|
} else if (policyPreset.value === 'public') {
|
|
policyMode.value = 'upsert';
|
|
policyTextarea.value = publicPolicyTemplate || policyTextarea.value;
|
|
} else {
|
|
policyMode.value = 'upsert';
|
|
}
|
|
});
|
|
|
|
const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper');
|
|
const updateBulkDeleteState = () => {
|
|
const selectedCount = selectedRows.size;
|
|
if (bulkDeleteButton) {
|
|
const shouldShow = Boolean(bulkDeleteEndpoint) && (selectedCount > 0 || bulkDeleting);
|
|
bulkDeleteButton.disabled = !bulkDeleteEndpoint || selectedCount === 0 || bulkDeleting;
|
|
if (bulkDeleteLabel) {
|
|
bulkDeleteLabel.textContent = selectedCount ? `Delete (${selectedCount})` : 'Delete';
|
|
}
|
|
if (bulkActionsWrapper) {
|
|
bulkActionsWrapper.classList.toggle('d-none', !shouldShow);
|
|
}
|
|
}
|
|
if (bulkDeleteConfirm) {
|
|
bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting;
|
|
}
|
|
if (selectAllCheckbox) {
|
|
const filesInView = visibleItems.filter(item => item.type === 'file');
|
|
const total = filesInView.length;
|
|
const visibleSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length;
|
|
selectAllCheckbox.disabled = total === 0;
|
|
selectAllCheckbox.checked = visibleSelectedCount > 0 && visibleSelectedCount === total && total > 0;
|
|
selectAllCheckbox.indeterminate = visibleSelectedCount > 0 && visibleSelectedCount < total;
|
|
}
|
|
};
|
|
|
|
function toggleRowSelection(row, shouldSelect) {
|
|
if (!row || !row.dataset.key) return;
|
|
if (shouldSelect) {
|
|
selectedRows.set(row.dataset.key, row);
|
|
} else {
|
|
selectedRows.delete(row.dataset.key);
|
|
}
|
|
updateBulkDeleteState();
|
|
}
|
|
|
|
const renderBulkDeletePreview = () => {
|
|
if (!bulkDeleteList) return;
|
|
const keys = Array.from(selectedRows.keys());
|
|
bulkDeleteList.innerHTML = '';
|
|
if (bulkDeleteCount) {
|
|
const label = keys.length === 1 ? 'object' : 'objects';
|
|
bulkDeleteCount.textContent = `${keys.length} ${label} selected`;
|
|
}
|
|
if (!keys.length) {
|
|
const empty = document.createElement('li');
|
|
empty.className = 'list-group-item py-2 small text-muted';
|
|
empty.textContent = 'No objects selected.';
|
|
bulkDeleteList.appendChild(empty);
|
|
if (bulkDeleteStatus) {
|
|
bulkDeleteStatus.textContent = '';
|
|
}
|
|
return;
|
|
}
|
|
const preview = keys.slice(0, 6);
|
|
preview.forEach((key) => {
|
|
const item = document.createElement('li');
|
|
item.className = 'list-group-item py-1 small text-break';
|
|
item.textContent = key;
|
|
bulkDeleteList.appendChild(item);
|
|
});
|
|
if (bulkDeleteStatus) {
|
|
bulkDeleteStatus.textContent = keys.length > preview.length ? `+${keys.length - preview.length} more not shown` : '';
|
|
}
|
|
};
|
|
|
|
const openBulkDeleteModal = () => {
|
|
if (!bulkDeleteModal) {
|
|
return;
|
|
}
|
|
if (selectedRows.size === 0) {
|
|
showMessage({ title: 'Select objects', body: 'Choose at least one object to delete.', variant: 'warning' });
|
|
return;
|
|
}
|
|
renderBulkDeletePreview();
|
|
if (bulkDeletePurge) {
|
|
bulkDeletePurge.checked = false;
|
|
}
|
|
if (bulkDeleteConfirm) {
|
|
bulkDeleteConfirm.disabled = bulkDeleting;
|
|
bulkDeleteConfirm.textContent = bulkDeleting ? 'Deleting…' : 'Delete objects';
|
|
}
|
|
bulkDeleteModal.show();
|
|
};
|
|
|
|
const performBulkDelete = async () => {
|
|
if (!bulkDeleteEndpoint || selectedRows.size === 0 || !bulkDeleteConfirm) {
|
|
return;
|
|
}
|
|
bulkDeleting = true;
|
|
bulkDeleteConfirm.disabled = true;
|
|
bulkDeleteConfirm.textContent = 'Deleting…';
|
|
updateBulkDeleteState();
|
|
const payload = {
|
|
keys: Array.from(selectedRows.keys()),
|
|
};
|
|
if (versioningEnabled && bulkDeletePurge?.checked) {
|
|
payload.purge_versions = true;
|
|
}
|
|
try {
|
|
const response = await fetch(bulkDeleteEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
let data = {};
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok || data.error) {
|
|
throw new Error(data.error || data.message || 'Unable to delete selected objects');
|
|
}
|
|
bulkDeleteModal?.hide();
|
|
const deletedCount = Array.isArray(data.deleted) ? data.deleted.length : selectedRows.size;
|
|
const errorCount = Array.isArray(data.errors) ? data.errors.length : 0;
|
|
const messageParts = [];
|
|
if (deletedCount) {
|
|
messageParts.push(`${deletedCount} deleted`);
|
|
}
|
|
if (errorCount) {
|
|
messageParts.push(`${errorCount} failed`);
|
|
}
|
|
const summary = messageParts.length ? messageParts.join(', ') : 'Bulk delete finished';
|
|
showMessage({ title: 'Bulk delete complete', body: data.message || summary, variant: errorCount ? 'warning' : 'success' });
|
|
window.setTimeout(() => window.location.reload(), 600);
|
|
} catch (error) {
|
|
bulkDeleteModal?.hide();
|
|
showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete selected objects', variant: 'danger' });
|
|
} finally {
|
|
bulkDeleting = false;
|
|
if (bulkDeleteConfirm) {
|
|
bulkDeleteConfirm.disabled = false;
|
|
bulkDeleteConfirm.textContent = 'Delete objects';
|
|
}
|
|
updateBulkDeleteState();
|
|
}
|
|
};
|
|
|
|
const updateGeneratePresignState = () => {
|
|
if (!generatePresignButton) return;
|
|
if (isGeneratingPresign) {
|
|
generatePresignButton.disabled = true;
|
|
generatePresignButton.textContent = 'Generating…';
|
|
return;
|
|
}
|
|
generatePresignButton.textContent = 'Generate link';
|
|
generatePresignButton.disabled = !activeRow;
|
|
};
|
|
|
|
const requestPresignedUrl = async () => {
|
|
if (!activeRow) {
|
|
showMessage({ title: 'Select an object', body: 'Choose an object before generating a presigned URL.', variant: 'warning' });
|
|
return;
|
|
}
|
|
const endpoint = activeRow.dataset.presignEndpoint;
|
|
if (!endpoint) {
|
|
showMessage({ title: 'Unavailable', body: 'Presign endpoint unavailable for this object.', variant: 'danger' });
|
|
return;
|
|
}
|
|
if (isGeneratingPresign) {
|
|
return;
|
|
}
|
|
isGeneratingPresign = true;
|
|
updateGeneratePresignState();
|
|
presignLink.value = '';
|
|
try {
|
|
const payload = {
|
|
method: presignMethod?.value || 'GET',
|
|
expires_in: Number(presignTtl?.value) || 900,
|
|
};
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Unable to generate presigned URL');
|
|
}
|
|
presignLink.value = data.url;
|
|
} catch (error) {
|
|
presignModal?.hide();
|
|
showMessage({ title: 'Presign failed', body: (error && error.message) || 'Unable to generate presigned URL', variant: 'danger' });
|
|
} finally {
|
|
isGeneratingPresign = false;
|
|
updateGeneratePresignState();
|
|
}
|
|
};
|
|
|
|
const renderMetadata = (metadata) => {
|
|
if (!previewMetadata || !previewMetadataList) return;
|
|
previewMetadataList.innerHTML = '';
|
|
if (!metadata || Object.keys(metadata).length === 0) {
|
|
previewMetadata.classList.add('d-none');
|
|
return;
|
|
}
|
|
previewMetadata.classList.remove('d-none');
|
|
Object.entries(metadata).forEach(([key, value]) => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'metadata-entry';
|
|
const label = document.createElement('div');
|
|
label.className = 'metadata-key small';
|
|
label.textContent = key;
|
|
const val = document.createElement('div');
|
|
val.className = 'metadata-value text-break';
|
|
val.textContent = value;
|
|
wrapper.appendChild(label);
|
|
wrapper.appendChild(val);
|
|
previewMetadataList.appendChild(wrapper);
|
|
});
|
|
};
|
|
|
|
const describeVersionReason = (reason) => {
|
|
switch (reason) {
|
|
case 'delete':
|
|
return 'delete marker';
|
|
case 'restore-overwrite':
|
|
return 'restore overwrite';
|
|
default:
|
|
return reason || 'update';
|
|
}
|
|
};
|
|
|
|
const confirmVersionRestore = (row, version, label = null, onConfirm) => {
|
|
if (!version) return;
|
|
const timestamp = version.archived_at ? new Date(version.archived_at).toLocaleString() : version.version_id;
|
|
const sizeLabel = formatBytes(Number(version.size) || 0);
|
|
const reasonLabel = describeVersionReason(version.reason);
|
|
const targetLabel = label || row?.dataset.key || 'this object';
|
|
const metadata = version.metadata && typeof version.metadata === 'object' ? Object.entries(version.metadata) : [];
|
|
const metadataHtml = metadata.length
|
|
? `<div class="mt-3"><div class="fw-semibold text-uppercase small">Metadata</div><hr class="my-2"><div class="metadata-stack small">${metadata
|
|
.map(
|
|
([key, value]) =>
|
|
`<div class="metadata-entry"><div class="metadata-key small">${escapeHtml(key)}</div><div class="metadata-value text-break">${escapeHtml(value)}</div></div>`
|
|
)
|
|
.join('')}</div></div>`
|
|
: '';
|
|
const summaryHtml = `
|
|
<div class="small">
|
|
<div><strong>Target:</strong> ${escapeHtml(targetLabel)}</div>
|
|
<div><strong>Version ID:</strong> ${escapeHtml(version.version_id)}</div>
|
|
<div><strong>Timestamp:</strong> ${escapeHtml(timestamp)}</div>
|
|
<div><strong>Size:</strong> ${escapeHtml(sizeLabel)}</div>
|
|
<div><strong>Reason:</strong> ${escapeHtml(reasonLabel)}</div>
|
|
</div>
|
|
${metadataHtml}
|
|
`;
|
|
const fallbackText = `Restore ${targetLabel} from ${timestamp}? Size ${sizeLabel}. Reason: ${reasonLabel}.`;
|
|
showMessage({
|
|
title: 'Restore archived version?',
|
|
body: fallbackText,
|
|
bodyHtml: summaryHtml,
|
|
variant: 'warning',
|
|
actionText: 'Restore version',
|
|
onAction: () => {
|
|
if (typeof onConfirm === 'function') {
|
|
onConfirm();
|
|
} else {
|
|
restoreVersion(row, version);
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const updateArchivedCount = (count) => {
|
|
if (!archivedCountBadge) return;
|
|
const label = count === 1 ? 'item' : 'items';
|
|
archivedCountBadge.textContent = `${count} ${label}`;
|
|
};
|
|
|
|
function renderArchivedRows(items) {
|
|
if (!archivedBody) return;
|
|
archivedBody.innerHTML = '';
|
|
if (!items || items.length === 0) {
|
|
archivedBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">No archived-only objects.</td></tr>';
|
|
updateArchivedCount(0);
|
|
return;
|
|
}
|
|
updateArchivedCount(items.length);
|
|
items.forEach((item) => {
|
|
const row = document.createElement('tr');
|
|
|
|
const keyCell = document.createElement('td');
|
|
const keyLabel = document.createElement('div');
|
|
keyLabel.className = 'fw-semibold text-break';
|
|
keyLabel.textContent = item.key;
|
|
const badgeWrap = document.createElement('div');
|
|
badgeWrap.className = 'mt-1';
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge text-bg-warning';
|
|
badge.textContent = 'Archived';
|
|
badgeWrap.appendChild(badge);
|
|
keyCell.appendChild(keyLabel);
|
|
keyCell.appendChild(badgeWrap);
|
|
|
|
const latestCell = document.createElement('td');
|
|
if (item.latest) {
|
|
const ts = item.latest.archived_at ? new Date(item.latest.archived_at).toLocaleString() : item.latest.version_id;
|
|
const sizeLabel = formatBytes(Number(item.latest.size) || 0);
|
|
latestCell.innerHTML = `<div class="small">${ts}</div><div class="text-muted small">${sizeLabel} · ${describeVersionReason(item.latest.reason)}</div>`;
|
|
} else {
|
|
latestCell.innerHTML = '<span class="text-muted small">Unknown</span>';
|
|
}
|
|
|
|
const countCell = document.createElement('td');
|
|
countCell.className = 'text-end text-muted';
|
|
countCell.textContent = item.versions;
|
|
|
|
const actionsCell = document.createElement('td');
|
|
actionsCell.className = 'text-end';
|
|
const btnGroup = document.createElement('div');
|
|
btnGroup.className = 'btn-group btn-group-sm';
|
|
|
|
const restoreButton = document.createElement('button');
|
|
restoreButton.type = 'button';
|
|
restoreButton.className = 'btn btn-outline-primary';
|
|
restoreButton.textContent = 'Restore';
|
|
restoreButton.disabled = !item.latest || !item.restore_url;
|
|
restoreButton.addEventListener('click', () => confirmVersionRestore(null, item.latest, item.key, () => restoreArchivedObject(item)));
|
|
|
|
const purgeButton = document.createElement('button');
|
|
purgeButton.type = 'button';
|
|
purgeButton.className = 'btn btn-outline-danger';
|
|
purgeButton.textContent = 'Delete versions';
|
|
purgeButton.addEventListener('click', () => confirmArchivedPurge(item));
|
|
|
|
btnGroup.appendChild(restoreButton);
|
|
btnGroup.appendChild(purgeButton);
|
|
actionsCell.appendChild(btnGroup);
|
|
|
|
row.appendChild(keyCell);
|
|
row.appendChild(latestCell);
|
|
row.appendChild(countCell);
|
|
row.appendChild(actionsCell);
|
|
archivedBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function restoreArchivedObject(item) {
|
|
if (!item?.restore_url) return;
|
|
try {
|
|
const response = await fetch(item.restore_url, { method: 'POST' });
|
|
let data = {};
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Unable to restore archived object');
|
|
}
|
|
showMessage({ title: 'Restore scheduled', body: data.message || 'Object restored from archive.', variant: 'success' });
|
|
await loadArchivedObjects();
|
|
window.setTimeout(() => window.location.reload(), 600);
|
|
} catch (error) {
|
|
showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore archived object', variant: 'danger' });
|
|
}
|
|
}
|
|
|
|
async function purgeArchivedObject(item) {
|
|
if (!item?.purge_url) return;
|
|
try {
|
|
const response = await fetch(item.purge_url, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
let data = {};
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Unable to delete archived versions');
|
|
}
|
|
showMessage({ title: 'Archived versions removed', body: data.message || 'All archived data for this key has been deleted.', variant: 'success' });
|
|
await loadArchivedObjects();
|
|
} catch (error) {
|
|
showMessage({ title: 'Delete failed', body: (error && error.message) || 'Unable to delete archived versions', variant: 'danger' });
|
|
}
|
|
}
|
|
|
|
function confirmArchivedPurge(item) {
|
|
const label = item?.key || 'this object';
|
|
const count = item?.versions || 0;
|
|
const countLabel = count === 1 ? 'version' : 'versions';
|
|
showMessage({
|
|
title: 'Delete archived versions?',
|
|
body: `Permanently remove ${count} archived ${countLabel} for ${label}? This cannot be undone.`,
|
|
variant: 'danger',
|
|
actionText: 'Delete versions',
|
|
onAction: () => purgeArchivedObject(item),
|
|
});
|
|
}
|
|
|
|
async function loadArchivedObjects() {
|
|
if (!archivedEndpoint || !archivedBody) return;
|
|
archivedBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">Loading…</td></tr>';
|
|
try {
|
|
const response = await fetch(archivedEndpoint);
|
|
let data = {};
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Unable to load archived objects');
|
|
}
|
|
const items = Array.isArray(data.objects) ? data.objects : [];
|
|
renderArchivedRows(items);
|
|
} catch (error) {
|
|
archivedBody.innerHTML = `<tr><td colspan="4" class="text-center text-danger py-3">${(error && error.message) || 'Unable to load archived objects'}</td></tr>`;
|
|
updateArchivedCount(0);
|
|
}
|
|
}
|
|
|
|
if (archivedRefreshButton) {
|
|
archivedRefreshButton.addEventListener('click', () => loadArchivedObjects());
|
|
}
|
|
if (archivedCard && archivedEndpoint) {
|
|
loadArchivedObjects();
|
|
}
|
|
|
|
async function restoreVersion(row, version) {
|
|
if (!row || !version?.version_id) return;
|
|
const template = row.dataset.restoreTemplate;
|
|
if (!template) return;
|
|
const url = template.replace('VERSION_ID_PLACEHOLDER', version.version_id);
|
|
try {
|
|
const response = await fetch(url, { method: 'POST' });
|
|
let data = {};
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Unable to restore version');
|
|
}
|
|
const endpoint = row.dataset.versionsEndpoint;
|
|
if (endpoint) {
|
|
versionsCache.delete(endpoint);
|
|
}
|
|
await loadObjectVersions(row, { force: true });
|
|
showMessage({ title: 'Version restored', body: data.message || 'The selected version has been restored.', variant: 'success' });
|
|
window.setTimeout(() => window.location.reload(), 500);
|
|
} catch (error) {
|
|
showMessage({ title: 'Restore failed', body: (error && error.message) || 'Unable to restore version', variant: 'danger' });
|
|
}
|
|
}
|
|
|
|
function renderVersionEntries(entries, row) {
|
|
if (!versionList) return;
|
|
if (!entries || entries.length === 0) {
|
|
versionList.innerHTML = '<p class="text-muted small mb-0">No previous versions yet.</p>';
|
|
return;
|
|
}
|
|
versionList.innerHTML = '';
|
|
entries.forEach((entry, index) => {
|
|
const versionNumber = index + 1;
|
|
const item = document.createElement('div');
|
|
item.className = 'd-flex align-items-center justify-content-between py-2 border-bottom';
|
|
const textStack = document.createElement('div');
|
|
textStack.className = 'me-3';
|
|
const heading = document.createElement('div');
|
|
heading.className = 'd-flex align-items-center';
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge text-bg-secondary me-2';
|
|
badge.textContent = `#${versionNumber}`;
|
|
const title = document.createElement('div');
|
|
title.className = 'fw-semibold small';
|
|
const timestamp = entry.archived_at ? new Date(entry.archived_at).toLocaleString() : entry.version_id;
|
|
title.textContent = timestamp;
|
|
heading.appendChild(badge);
|
|
heading.appendChild(title);
|
|
const meta = document.createElement('div');
|
|
meta.className = 'text-muted small';
|
|
const reason = describeVersionReason(entry.reason);
|
|
const sizeLabel = formatBytes(Number(entry.size) || 0);
|
|
meta.textContent = `${sizeLabel} · ${reason}`;
|
|
textStack.appendChild(heading);
|
|
textStack.appendChild(meta);
|
|
const restoreButton = document.createElement('button');
|
|
restoreButton.type = 'button';
|
|
restoreButton.className = 'btn btn-outline-primary btn-sm';
|
|
restoreButton.textContent = 'Restore';
|
|
restoreButton.addEventListener('click', () => confirmVersionRestore(row, entry));
|
|
item.appendChild(textStack);
|
|
item.appendChild(restoreButton);
|
|
versionList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function loadObjectVersions(row, { force = false } = {}) {
|
|
if (!versionPanel || !versionList || !versioningEnabled) {
|
|
versionPanel?.classList.add('d-none');
|
|
return;
|
|
}
|
|
if (!row) {
|
|
versionPanel.classList.add('d-none');
|
|
return;
|
|
}
|
|
const endpoint = row.dataset.versionsEndpoint;
|
|
if (!endpoint) {
|
|
versionPanel.classList.add('d-none');
|
|
return;
|
|
}
|
|
versionPanel.classList.remove('d-none');
|
|
if (!force && versionsCache.has(endpoint)) {
|
|
renderVersionEntries(versionsCache.get(endpoint), row);
|
|
return;
|
|
}
|
|
versionList.innerHTML = '<div class="text-muted small">Loading versions…</div>';
|
|
try {
|
|
const response = await fetch(endpoint);
|
|
let data = {};
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Unable to load versions');
|
|
}
|
|
const entries = Array.isArray(data.versions) ? data.versions : [];
|
|
versionsCache.set(endpoint, entries);
|
|
renderVersionEntries(entries, row);
|
|
} catch (error) {
|
|
versionList.innerHTML = `<p class="text-danger small mb-0">${(error && error.message) || 'Unable to load versions'}</p>`;
|
|
}
|
|
}
|
|
|
|
renderMetadata(null);
|
|
const deleteModalEl = document.getElementById('deleteObjectModal');
|
|
const deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
|
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
|
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
|
|
|
const resetPreviewMedia = () => {
|
|
[previewImage, previewVideo, previewIframe].forEach((el) => {
|
|
el.classList.add('d-none');
|
|
if (el.tagName === 'VIDEO') {
|
|
el.pause();
|
|
el.removeAttribute('src');
|
|
}
|
|
if (el.tagName === 'IFRAME') {
|
|
el.setAttribute('src', 'about:blank');
|
|
}
|
|
});
|
|
previewPlaceholder.classList.remove('d-none');
|
|
};
|
|
|
|
function metadataFromRow(row) {
|
|
if (!row || !row.dataset.metadata) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(row.dataset.metadata);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to parse metadata for row', err);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function selectRow(row) {
|
|
document.querySelectorAll('[data-object-row]').forEach((r) => r.classList.remove('table-active'));
|
|
row.classList.add('table-active');
|
|
previewEmpty.classList.add('d-none');
|
|
previewPanel.classList.remove('d-none');
|
|
activeRow = row;
|
|
renderMetadata(metadataFromRow(row));
|
|
|
|
previewKey.textContent = row.dataset.key;
|
|
previewSize.textContent = formatBytes(Number(row.dataset.size));
|
|
previewModified.textContent = row.dataset.lastModified;
|
|
previewEtag.textContent = row.dataset.etag;
|
|
downloadButton.href = row.dataset.downloadUrl;
|
|
downloadButton.classList.remove('disabled');
|
|
if (presignButton) {
|
|
presignButton.dataset.endpoint = row.dataset.presignEndpoint;
|
|
presignButton.disabled = false;
|
|
}
|
|
if (generatePresignButton) {
|
|
generatePresignButton.disabled = false;
|
|
}
|
|
updateGeneratePresignState();
|
|
if (versioningEnabled) {
|
|
loadObjectVersions(row);
|
|
}
|
|
|
|
resetPreviewMedia();
|
|
const previewUrl = row.dataset.previewUrl;
|
|
const lower = row.dataset.key.toLowerCase();
|
|
if (lower.match(/\.(png|jpg|jpeg|gif|webp|svg)$/)) {
|
|
previewImage.src = previewUrl;
|
|
previewImage.classList.remove('d-none');
|
|
previewPlaceholder.classList.add('d-none');
|
|
} else if (lower.match(/\.(mp4|webm|ogg)$/)) {
|
|
previewVideo.src = previewUrl;
|
|
previewVideo.classList.remove('d-none');
|
|
previewPlaceholder.classList.add('d-none');
|
|
} else if (lower.match(/\.(txt|log|json|md|csv)$/)) {
|
|
previewIframe.src = previewUrl;
|
|
previewIframe.classList.remove('d-none');
|
|
previewPlaceholder.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
updateBulkDeleteState();
|
|
|
|
function initFolderNavigation() {
|
|
if (hasFolders()) {
|
|
renderBreadcrumb(currentPrefix);
|
|
renderObjectsView();
|
|
}
|
|
if (typeof updateFolderViewStatus === 'function') {
|
|
updateFolderViewStatus();
|
|
}
|
|
if (typeof updateFilterWarning === 'function') {
|
|
updateFilterWarning();
|
|
}
|
|
}
|
|
|
|
bulkDeleteButton?.addEventListener('click', () => openBulkDeleteModal());
|
|
bulkDeleteConfirm?.addEventListener('click', () => performBulkDelete());
|
|
|
|
const filterWarning = document.getElementById('filter-warning');
|
|
const filterWarningText = document.getElementById('filter-warning-text');
|
|
const folderViewStatus = document.getElementById('folder-view-status');
|
|
|
|
const updateFilterWarning = () => {
|
|
if (!filterWarning) return;
|
|
const isFiltering = currentFilterTerm.length > 0;
|
|
if (isFiltering && hasMoreObjects) {
|
|
filterWarning.classList.remove('d-none');
|
|
} else {
|
|
filterWarning.classList.add('d-none');
|
|
}
|
|
};
|
|
|
|
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
|
currentFilterTerm = event.target.value.toLowerCase();
|
|
updateFilterWarning();
|
|
refreshVirtualList();
|
|
});
|
|
|
|
refreshVersionsButton?.addEventListener('click', () => {
|
|
if (!activeRow) {
|
|
versionList.innerHTML = '<p class="text-muted small mb-0">Select an object to view versions.</p>';
|
|
return;
|
|
}
|
|
const endpoint = activeRow.dataset.versionsEndpoint;
|
|
if (endpoint) {
|
|
versionsCache.delete(endpoint);
|
|
}
|
|
loadObjectVersions(activeRow, { force: true });
|
|
});
|
|
|
|
presignButton?.addEventListener('click', () => {
|
|
if (!activeRow) {
|
|
showMessage({ title: 'Select an object', body: 'Choose an object before generating a presigned URL.', variant: 'warning' });
|
|
return;
|
|
}
|
|
presignLink.value = '';
|
|
presignModal?.show();
|
|
requestPresignedUrl();
|
|
});
|
|
|
|
generatePresignButton?.addEventListener('click', () => {
|
|
requestPresignedUrl();
|
|
});
|
|
|
|
copyPresignLink?.addEventListener('click', async () => {
|
|
if (!presignLink?.value) {
|
|
return;
|
|
}
|
|
|
|
const fallbackCopy = (text) => {
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-999999px';
|
|
textArea.style.top = '-999999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
let success = false;
|
|
try {
|
|
success = document.execCommand('copy');
|
|
} catch (err) {
|
|
success = false;
|
|
}
|
|
textArea.remove();
|
|
return success;
|
|
};
|
|
|
|
let copied = false;
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
try {
|
|
await navigator.clipboard.writeText(presignLink.value);
|
|
copied = true;
|
|
} catch (error) {
|
|
|
|
}
|
|
}
|
|
|
|
if (!copied) {
|
|
copied = fallbackCopy(presignLink.value);
|
|
}
|
|
|
|
if (copied) {
|
|
copyPresignLink.textContent = 'Copied!';
|
|
window.setTimeout(() => {
|
|
copyPresignLink.textContent = copyPresignDefaultLabel;
|
|
}, 1500);
|
|
} else {
|
|
showMessage({ title: 'Copy Failed', body: 'Unable to copy link to clipboard. Please select the link and copy manually.', variant: 'warning' });
|
|
}
|
|
});
|
|
|
|
if (uploadForm && uploadFileInput) {
|
|
const uploadSubmitBtn = document.getElementById('uploadSubmitBtn');
|
|
const uploadCancelBtn = document.getElementById('uploadCancelBtn');
|
|
const uploadBtnText = document.getElementById('uploadBtnText');
|
|
const bulkUploadProgress = document.getElementById('bulkUploadProgress');
|
|
const bulkUploadStatus = document.getElementById('bulkUploadStatus');
|
|
const bulkUploadCounter = document.getElementById('bulkUploadCounter');
|
|
const bulkUploadProgressBar = document.getElementById('bulkUploadProgressBar');
|
|
const bulkUploadCurrentFile = document.getElementById('bulkUploadCurrentFile');
|
|
const bulkUploadResults = document.getElementById('bulkUploadResults');
|
|
const bulkUploadSuccessAlert = document.getElementById('bulkUploadSuccessAlert');
|
|
const bulkUploadErrorAlert = document.getElementById('bulkUploadErrorAlert');
|
|
const bulkUploadSuccessCount = document.getElementById('bulkUploadSuccessCount');
|
|
const bulkUploadErrorCount = document.getElementById('bulkUploadErrorCount');
|
|
const bulkUploadErrorList = document.getElementById('bulkUploadErrorList');
|
|
const uploadKeyPrefix = document.getElementById('uploadKeyPrefix');
|
|
const singleFileOptions = document.getElementById('singleFileOptions');
|
|
const floatingProgress = document.getElementById('floatingUploadProgress');
|
|
const floatingProgressBar = document.getElementById('floatingUploadProgressBar');
|
|
const floatingProgressStatus = document.getElementById('floatingUploadStatus');
|
|
const floatingProgressTitle = document.getElementById('floatingUploadTitle');
|
|
const floatingProgressExpand = document.getElementById('floatingUploadExpand');
|
|
const uploadQueueContainer = document.getElementById('uploadQueueContainer');
|
|
const uploadQueueList = document.getElementById('uploadQueueList');
|
|
const uploadQueueCount = document.getElementById('uploadQueueCount');
|
|
const clearUploadQueueBtn = document.getElementById('clearUploadQueueBtn');
|
|
let isUploading = false;
|
|
let uploadQueue = [];
|
|
let uploadStats = {
|
|
totalFiles: 0,
|
|
completedFiles: 0,
|
|
totalBytes: 0,
|
|
uploadedBytes: 0,
|
|
currentFileBytes: 0,
|
|
currentFileLoaded: 0,
|
|
currentFileName: ''
|
|
};
|
|
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (isUploading) {
|
|
e.preventDefault();
|
|
e.returnValue = 'Upload in progress. Are you sure you want to leave?';
|
|
return e.returnValue;
|
|
}
|
|
});
|
|
|
|
const showFloatingProgress = () => {
|
|
if (floatingProgress) {
|
|
floatingProgress.classList.remove('d-none');
|
|
}
|
|
};
|
|
|
|
const hideFloatingProgress = () => {
|
|
if (floatingProgress) {
|
|
floatingProgress.classList.add('d-none');
|
|
}
|
|
};
|
|
|
|
const updateFloatingProgress = () => {
|
|
const { totalFiles, completedFiles, totalBytes, uploadedBytes, currentFileLoaded, currentFileName } = uploadStats;
|
|
const effectiveUploaded = uploadedBytes + currentFileLoaded;
|
|
|
|
if (floatingProgressBar && totalBytes > 0) {
|
|
const percent = Math.round((effectiveUploaded / totalBytes) * 100);
|
|
floatingProgressBar.style.width = `${percent}%`;
|
|
}
|
|
if (floatingProgressStatus) {
|
|
const bytesText = `${formatBytes(effectiveUploaded)} / ${formatBytes(totalBytes)}`;
|
|
const queuedCount = uploadQueue.length;
|
|
let statusText = `${completedFiles}/${totalFiles} files`;
|
|
if (queuedCount > 0) {
|
|
statusText += ` (+${queuedCount} queued)`;
|
|
}
|
|
statusText += ` • ${bytesText}`;
|
|
floatingProgressStatus.textContent = statusText;
|
|
}
|
|
if (floatingProgressTitle) {
|
|
const remaining = totalFiles - completedFiles;
|
|
const queuedCount = uploadQueue.length;
|
|
let title = `Uploading ${remaining} file${remaining !== 1 ? 's' : ''}`;
|
|
if (queuedCount > 0) {
|
|
title += ` (+${queuedCount} queued)`;
|
|
}
|
|
floatingProgressTitle.textContent = title + '...';
|
|
}
|
|
};
|
|
|
|
floatingProgressExpand?.addEventListener('click', () => {
|
|
if (uploadModal) {
|
|
uploadModal.show();
|
|
}
|
|
});
|
|
|
|
const refreshUploadDropLabel = () => {
|
|
if (!uploadDropZoneLabel) return;
|
|
if (isUploading) {
|
|
uploadDropZoneLabel.textContent = 'Drop files here to add to queue';
|
|
if (singleFileOptions) singleFileOptions.classList.add('d-none');
|
|
return;
|
|
}
|
|
const files = uploadFileInput.files;
|
|
if (!files || files.length === 0) {
|
|
uploadDropZoneLabel.textContent = 'No file selected';
|
|
if (singleFileOptions) singleFileOptions.classList.remove('d-none');
|
|
return;
|
|
}
|
|
uploadDropZoneLabel.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`;
|
|
|
|
if (singleFileOptions) {
|
|
singleFileOptions.classList.toggle('d-none', files.length > 1);
|
|
}
|
|
};
|
|
|
|
const updateUploadBtnText = () => {
|
|
if (!uploadBtnText) return;
|
|
if (isUploading) {
|
|
const files = uploadFileInput.files;
|
|
if (files && files.length > 0) {
|
|
uploadBtnText.textContent = `Add ${files.length} to queue`;
|
|
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
|
|
} else {
|
|
uploadBtnText.textContent = 'Uploading...';
|
|
}
|
|
return;
|
|
}
|
|
const files = uploadFileInput.files;
|
|
if (!files || files.length <= 1) {
|
|
uploadBtnText.textContent = 'Upload';
|
|
} else {
|
|
uploadBtnText.textContent = `Upload ${files.length} files`;
|
|
}
|
|
};
|
|
|
|
const resetUploadUI = () => {
|
|
if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
|
|
if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
|
|
if (bulkUploadSuccessAlert) bulkUploadSuccessAlert.classList.remove('d-none');
|
|
if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.add('d-none');
|
|
if (bulkUploadErrorList) bulkUploadErrorList.innerHTML = '';
|
|
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
|
|
if (uploadFileInput) uploadFileInput.disabled = false;
|
|
const progressStack = document.querySelector('[data-upload-progress]');
|
|
if (progressStack) progressStack.innerHTML = '';
|
|
if (uploadDropZone) {
|
|
uploadDropZone.classList.remove('upload-locked');
|
|
uploadDropZone.style.pointerEvents = '';
|
|
}
|
|
isUploading = false;
|
|
hideFloatingProgress();
|
|
};
|
|
|
|
const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
|
|
const CHUNK_SIZE = 8 * 1024 * 1024;
|
|
const uploadProgressStack = document.querySelector('[data-upload-progress]');
|
|
const multipartInitUrl = uploadForm.dataset.multipartInitUrl;
|
|
const multipartPartTemplate = uploadForm.dataset.multipartPartTemplate;
|
|
const multipartCompleteTemplate = uploadForm.dataset.multipartCompleteTemplate;
|
|
const multipartAbortTemplate = uploadForm.dataset.multipartAbortTemplate;
|
|
|
|
const createProgressItem = (file) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'upload-progress-item';
|
|
item.dataset.state = 'uploading';
|
|
item.innerHTML = `
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="min-width-0 flex-grow-1">
|
|
<div class="file-name">${escapeHtml(file.name)}</div>
|
|
<div class="file-size">${formatBytes(file.size)}</div>
|
|
</div>
|
|
<div class="upload-status text-end ms-2">Preparing...</div>
|
|
</div>
|
|
<div class="progress-container">
|
|
<div class="progress">
|
|
<div class="progress-bar bg-primary" role="progressbar" style="width: 0%"></div>
|
|
</div>
|
|
<div class="progress-text">
|
|
<span class="progress-loaded">0 B</span>
|
|
<span class="progress-percent">0%</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return item;
|
|
};
|
|
|
|
const updateProgressItem = (item, { loaded, total, status, state, error }) => {
|
|
if (state) item.dataset.state = state;
|
|
const statusEl = item.querySelector('.upload-status');
|
|
const progressBar = item.querySelector('.progress-bar');
|
|
const progressLoaded = item.querySelector('.progress-loaded');
|
|
const progressPercent = item.querySelector('.progress-percent');
|
|
|
|
if (status) {
|
|
statusEl.textContent = status;
|
|
statusEl.className = 'upload-status text-end ms-2';
|
|
if (state === 'success') statusEl.classList.add('success');
|
|
if (state === 'error') statusEl.classList.add('error');
|
|
}
|
|
if (typeof loaded === 'number' && typeof total === 'number' && total > 0) {
|
|
const percent = Math.round((loaded / total) * 100);
|
|
progressBar.style.width = `${percent}%`;
|
|
progressLoaded.textContent = `${formatBytes(loaded)} / ${formatBytes(total)}`;
|
|
progressPercent.textContent = `${percent}%`;
|
|
}
|
|
if (error) {
|
|
const progressContainer = item.querySelector('.progress-container');
|
|
if (progressContainer) {
|
|
progressContainer.innerHTML = `<div class="text-danger small mt-1">${escapeHtml(error)}</div>`;
|
|
}
|
|
}
|
|
};
|
|
|
|
const uploadMultipart = async (file, objectKey, metadata, progressItem) => {
|
|
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
|
|
|
|
updateProgressItem(progressItem, { status: 'Initiating...', loaded: 0, total: file.size });
|
|
const initResp = await fetch(multipartInitUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
|
body: JSON.stringify({ object_key: objectKey, metadata })
|
|
});
|
|
if (!initResp.ok) {
|
|
const err = await initResp.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to initiate upload');
|
|
}
|
|
const { upload_id } = await initResp.json();
|
|
|
|
const partUrl = multipartPartTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
|
|
const completeUrl = multipartCompleteTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
|
|
const abortUrl = multipartAbortTemplate.replace('UPLOAD_ID_PLACEHOLDER', upload_id);
|
|
|
|
const parts = [];
|
|
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
|
|
let uploadedBytes = 0;
|
|
|
|
try {
|
|
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
|
const start = (partNumber - 1) * CHUNK_SIZE;
|
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
|
const chunk = file.slice(start, end);
|
|
|
|
updateProgressItem(progressItem, {
|
|
status: `Part ${partNumber}/${totalParts}`,
|
|
loaded: uploadedBytes,
|
|
total: file.size
|
|
});
|
|
uploadStats.currentFileLoaded = uploadedBytes;
|
|
updateFloatingProgress();
|
|
|
|
const partResp = await fetch(`${partUrl}?partNumber=${partNumber}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'X-CSRFToken': csrfToken || '',
|
|
'Content-Type': 'application/octet-stream'
|
|
},
|
|
body: chunk
|
|
});
|
|
|
|
if (!partResp.ok) {
|
|
const err = await partResp.json().catch(() => ({}));
|
|
throw new Error(err.error || `Part ${partNumber} failed`);
|
|
}
|
|
|
|
const partData = await partResp.json();
|
|
parts.push({ part_number: partNumber, etag: partData.etag });
|
|
uploadedBytes += chunk.size;
|
|
|
|
updateProgressItem(progressItem, {
|
|
loaded: uploadedBytes,
|
|
total: file.size
|
|
});
|
|
uploadStats.currentFileLoaded = uploadedBytes;
|
|
updateFloatingProgress();
|
|
}
|
|
|
|
updateProgressItem(progressItem, { status: 'Completing...', loaded: file.size, total: file.size });
|
|
const completeResp = await fetch(completeUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
|
body: JSON.stringify({ parts })
|
|
});
|
|
|
|
if (!completeResp.ok) {
|
|
const err = await completeResp.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to complete upload');
|
|
}
|
|
|
|
return await completeResp.json();
|
|
} catch (err) {
|
|
try {
|
|
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
|
|
} catch {}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const uploadRegular = async (file, objectKey, metadata, progressItem) => {
|
|
return new Promise((resolve, reject) => {
|
|
const formData = new FormData();
|
|
formData.append('object', file);
|
|
formData.append('object_key', objectKey);
|
|
if (metadata) formData.append('metadata', JSON.stringify(metadata));
|
|
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
|
|
if (csrfToken) formData.append('csrf_token', csrfToken);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', uploadForm.action, true);
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
updateProgressItem(progressItem, {
|
|
status: 'Uploading...',
|
|
loaded: e.loaded,
|
|
total: e.total
|
|
});
|
|
uploadStats.currentFileLoaded = e.loaded;
|
|
updateFloatingProgress();
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
if (data.status === 'error') {
|
|
reject(new Error(data.message || 'Upload failed'));
|
|
} else {
|
|
resolve(data);
|
|
}
|
|
} catch {
|
|
resolve({});
|
|
}
|
|
} else {
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
reject(new Error(data.message || `Upload failed (${xhr.status})`));
|
|
} catch {
|
|
reject(new Error(`Upload failed (${xhr.status})`));
|
|
}
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
|
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
|
|
|
|
xhr.send(formData);
|
|
});
|
|
};
|
|
|
|
const uploadSingleFile = async (file, keyPrefix = '', metadata = null, progressItem = null) => {
|
|
const objectKey = keyPrefix ? `${keyPrefix}${file.name}` : file.name;
|
|
const shouldUseMultipart = file.size >= MULTIPART_THRESHOLD && multipartInitUrl;
|
|
|
|
if (!progressItem && uploadProgressStack) {
|
|
progressItem = createProgressItem(file);
|
|
uploadProgressStack.appendChild(progressItem);
|
|
}
|
|
|
|
try {
|
|
let result;
|
|
if (shouldUseMultipart) {
|
|
updateProgressItem(progressItem, { status: 'Multipart upload...', loaded: 0, total: file.size });
|
|
result = await uploadMultipart(file, objectKey, metadata, progressItem);
|
|
} else {
|
|
updateProgressItem(progressItem, { status: 'Uploading...', loaded: 0, total: file.size });
|
|
result = await uploadRegular(file, objectKey, metadata, progressItem);
|
|
}
|
|
updateProgressItem(progressItem, { state: 'success', status: 'Complete', loaded: file.size, total: file.size });
|
|
return result;
|
|
} catch (err) {
|
|
updateProgressItem(progressItem, { state: 'error', status: 'Failed', error: err.message });
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const setUploadLockState = (locked) => {
|
|
if (uploadDropZone) {
|
|
uploadDropZone.classList.toggle('upload-locked', locked);
|
|
}
|
|
};
|
|
|
|
let uploadSuccessFiles = [];
|
|
let uploadErrorFiles = [];
|
|
let isProcessingQueue = false;
|
|
|
|
const updateQueueListDisplay = () => {
|
|
if (!uploadQueueList || !uploadQueueContainer || !uploadQueueCount) return;
|
|
if (uploadQueue.length === 0) {
|
|
uploadQueueContainer.classList.add('d-none');
|
|
return;
|
|
}
|
|
uploadQueueContainer.classList.remove('d-none');
|
|
uploadQueueCount.textContent = uploadQueue.length;
|
|
uploadQueueList.innerHTML = uploadQueue.map((item, idx) => `
|
|
<li class="d-flex align-items-center justify-content-between py-1 ${idx > 0 ? 'border-top' : ''}">
|
|
<span class="text-truncate me-2" style="max-width: 300px;" title="${escapeHtml(item.file.name)}">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="text-muted me-1" viewBox="0 0 16 16">
|
|
<path d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>
|
|
</svg>
|
|
${escapeHtml(item.file.name)}
|
|
</span>
|
|
<span class="text-muted">${formatBytes(item.file.size)}</span>
|
|
</li>
|
|
`).join('');
|
|
};
|
|
|
|
const addFilesToQueue = (files, keyPrefix, metadata) => {
|
|
for (const file of files) {
|
|
uploadQueue.push({ file, keyPrefix, metadata });
|
|
uploadStats.totalFiles++;
|
|
uploadStats.totalBytes += file.size;
|
|
}
|
|
updateFloatingProgress();
|
|
updateQueueListDisplay();
|
|
};
|
|
|
|
const clearUploadQueue = () => {
|
|
const clearedCount = uploadQueue.length;
|
|
if (clearedCount === 0) return;
|
|
for (const item of uploadQueue) {
|
|
uploadStats.totalFiles--;
|
|
uploadStats.totalBytes -= item.file.size;
|
|
}
|
|
uploadQueue.length = 0;
|
|
updateFloatingProgress();
|
|
updateQueueListDisplay();
|
|
};
|
|
|
|
if (clearUploadQueueBtn) {
|
|
clearUploadQueueBtn.addEventListener('click', clearUploadQueue);
|
|
}
|
|
|
|
const processUploadQueue = async () => {
|
|
if (isProcessingQueue) return;
|
|
isProcessingQueue = true;
|
|
|
|
while (uploadQueue.length > 0) {
|
|
const item = uploadQueue.shift();
|
|
const { file, keyPrefix, metadata } = item;
|
|
updateQueueListDisplay();
|
|
|
|
uploadStats.currentFileName = file.name;
|
|
uploadStats.currentFileBytes = file.size;
|
|
uploadStats.currentFileLoaded = 0;
|
|
|
|
if (bulkUploadCounter) {
|
|
const queuedCount = uploadQueue.length;
|
|
let counterText = `${uploadStats.completedFiles + 1}/${uploadStats.totalFiles}`;
|
|
if (queuedCount > 0) {
|
|
counterText += ` (+${queuedCount} queued)`;
|
|
}
|
|
bulkUploadCounter.textContent = counterText;
|
|
}
|
|
if (bulkUploadCurrentFile) {
|
|
bulkUploadCurrentFile.textContent = `Uploading: ${file.name}`;
|
|
}
|
|
if (bulkUploadProgressBar) {
|
|
const percent = Math.round(((uploadStats.completedFiles + 1) / uploadStats.totalFiles) * 100);
|
|
bulkUploadProgressBar.style.width = `${percent}%`;
|
|
}
|
|
updateFloatingProgress();
|
|
|
|
try {
|
|
await uploadSingleFile(file, keyPrefix, metadata);
|
|
uploadSuccessFiles.push(file.name);
|
|
} catch (error) {
|
|
uploadErrorFiles.push({ name: file.name, error: error.message || 'Unknown error' });
|
|
}
|
|
|
|
uploadStats.uploadedBytes += file.size;
|
|
uploadStats.completedFiles++;
|
|
uploadStats.currentFileLoaded = 0;
|
|
updateFloatingProgress();
|
|
}
|
|
|
|
isProcessingQueue = false;
|
|
|
|
if (uploadQueue.length === 0) {
|
|
finishUploadSession();
|
|
}
|
|
};
|
|
|
|
const finishUploadSession = () => {
|
|
if (bulkUploadProgress) bulkUploadProgress.classList.add('d-none');
|
|
if (bulkUploadResults) bulkUploadResults.classList.remove('d-none');
|
|
|
|
if (bulkUploadSuccessCount) bulkUploadSuccessCount.textContent = uploadSuccessFiles.length;
|
|
if (uploadSuccessFiles.length === 0 && bulkUploadSuccessAlert) {
|
|
bulkUploadSuccessAlert.classList.add('d-none');
|
|
}
|
|
|
|
if (uploadErrorFiles.length > 0) {
|
|
if (bulkUploadErrorCount) bulkUploadErrorCount.textContent = uploadErrorFiles.length;
|
|
if (bulkUploadErrorAlert) bulkUploadErrorAlert.classList.remove('d-none');
|
|
if (bulkUploadErrorList) {
|
|
bulkUploadErrorList.innerHTML = uploadErrorFiles
|
|
.map(f => `<li><strong>${escapeHtml(f.name)}</strong>: ${escapeHtml(f.error)}</li>`)
|
|
.join('');
|
|
}
|
|
}
|
|
|
|
isUploading = false;
|
|
setUploadLockState(false);
|
|
refreshUploadDropLabel();
|
|
updateUploadBtnText();
|
|
updateQueueListDisplay();
|
|
|
|
if (uploadSuccessFiles.length > 0) {
|
|
if (uploadBtnText) uploadBtnText.textContent = 'Refreshing...';
|
|
const objectsTabUrl = window.location.pathname + '?tab=objects';
|
|
window.setTimeout(() => window.location.href = objectsTabUrl, 800);
|
|
} else {
|
|
if (uploadSubmitBtn) uploadSubmitBtn.disabled = false;
|
|
if (uploadFileInput) uploadFileInput.disabled = false;
|
|
}
|
|
};
|
|
|
|
const performBulkUpload = async (files) => {
|
|
if (!files || files.length === 0) return;
|
|
|
|
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
|
|
const metadataRaw = uploadForm.querySelector('textarea[name="metadata"]')?.value?.trim();
|
|
let metadata = null;
|
|
if (metadataRaw) {
|
|
try {
|
|
metadata = JSON.parse(metadataRaw);
|
|
} catch {
|
|
showMessage({ title: 'Invalid metadata', body: 'Metadata must be valid JSON.', variant: 'danger' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!isUploading) {
|
|
isUploading = true;
|
|
uploadSuccessFiles = [];
|
|
uploadErrorFiles = [];
|
|
uploadStats = {
|
|
totalFiles: 0,
|
|
completedFiles: 0,
|
|
totalBytes: 0,
|
|
uploadedBytes: 0,
|
|
currentFileBytes: 0,
|
|
currentFileLoaded: 0,
|
|
currentFileName: ''
|
|
};
|
|
|
|
if (bulkUploadProgress) bulkUploadProgress.classList.remove('d-none');
|
|
if (bulkUploadResults) bulkUploadResults.classList.add('d-none');
|
|
if (uploadSubmitBtn) uploadSubmitBtn.disabled = true;
|
|
refreshUploadDropLabel();
|
|
updateUploadBtnText();
|
|
}
|
|
|
|
const fileCount = files.length;
|
|
addFilesToQueue(Array.from(files), keyPrefix, metadata);
|
|
|
|
if (uploadFileInput) {
|
|
uploadFileInput.value = '';
|
|
}
|
|
refreshUploadDropLabel();
|
|
updateUploadBtnText();
|
|
|
|
processUploadQueue();
|
|
};
|
|
|
|
refreshUploadDropLabel();
|
|
uploadFileInput.addEventListener('change', () => {
|
|
refreshUploadDropLabel();
|
|
updateUploadBtnText();
|
|
if (!isUploading) {
|
|
resetUploadUI();
|
|
}
|
|
});
|
|
uploadDropZone?.addEventListener('click', () => {
|
|
uploadFileInput?.click();
|
|
});
|
|
|
|
uploadForm.addEventListener('submit', async (event) => {
|
|
const files = uploadFileInput.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
|
|
|
|
if (files.length === 1 && !keyPrefix) {
|
|
const customKey = uploadForm.querySelector('input[name="object_key"]')?.value?.trim();
|
|
if (customKey) {
|
|
|
|
if (uploadSubmitBtn) {
|
|
uploadSubmitBtn.disabled = true;
|
|
if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (uploadSubmitBtn) {
|
|
uploadSubmitBtn.disabled = true;
|
|
if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
|
|
}
|
|
|
|
await performBulkUpload(Array.from(files));
|
|
});
|
|
|
|
uploadModalEl?.addEventListener('show.bs.modal', () => {
|
|
if (hasFolders() && currentPrefix) {
|
|
uploadKeyPrefix.value = currentPrefix;
|
|
|
|
const advancedToggle = document.querySelector('[data-bs-target="#advancedUploadOptions"]');
|
|
const advancedCollapse = document.getElementById('advancedUploadOptions');
|
|
if (advancedToggle && advancedCollapse && !advancedCollapse.classList.contains('show')) {
|
|
new bootstrap.Collapse(advancedCollapse, { show: true });
|
|
}
|
|
} else if (uploadKeyPrefix) {
|
|
|
|
uploadKeyPrefix.value = '';
|
|
}
|
|
});
|
|
|
|
uploadModalEl?.addEventListener('hide.bs.modal', (event) => {
|
|
if (isUploading) {
|
|
showFloatingProgress();
|
|
}
|
|
});
|
|
|
|
uploadModalEl?.addEventListener('hidden.bs.modal', () => {
|
|
if (!isUploading) {
|
|
resetUploadUI();
|
|
uploadFileInput.value = '';
|
|
refreshUploadDropLabel();
|
|
updateUploadBtnText();
|
|
}
|
|
});
|
|
|
|
uploadModalEl?.addEventListener('show.bs.modal', () => {
|
|
if (isUploading) {
|
|
hideFloatingProgress();
|
|
}
|
|
});
|
|
|
|
const preventDefaults = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
const wireDropTarget = (target, { highlightClass = '', autoOpenModal = false } = {}) => {
|
|
if (!target) return;
|
|
['dragenter', 'dragover'].forEach((eventName) => {
|
|
target.addEventListener(eventName, (event) => {
|
|
preventDefaults(event);
|
|
if (highlightClass) {
|
|
target.classList.add(highlightClass);
|
|
}
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach((eventName) => {
|
|
target.addEventListener(eventName, (event) => {
|
|
preventDefaults(event);
|
|
if (highlightClass) {
|
|
target.classList.remove(highlightClass);
|
|
}
|
|
});
|
|
});
|
|
target.addEventListener('drop', (event) => {
|
|
if (!event.dataTransfer?.files?.length) {
|
|
return;
|
|
}
|
|
if (isUploading) {
|
|
performBulkUpload(event.dataTransfer.files);
|
|
} else {
|
|
if (uploadFileInput) {
|
|
uploadFileInput.files = event.dataTransfer.files;
|
|
uploadFileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
if (autoOpenModal && uploadModal) {
|
|
uploadModal.show();
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
if (uploadDropZone) {
|
|
wireDropTarget(uploadDropZone, { highlightClass: 'is-dragover' });
|
|
}
|
|
|
|
if (objectsContainer) {
|
|
wireDropTarget(objectsContainer, { highlightClass: 'drag-over', autoOpenModal: true });
|
|
}
|
|
}
|
|
|
|
const bulkDownloadButton = document.querySelector('[data-bulk-download-trigger]');
|
|
const bulkDownloadEndpoint = document.getElementById('objects-drop-zone')?.dataset.bulkDownloadEndpoint;
|
|
|
|
const updateBulkDownloadState = () => {
|
|
if (!bulkDownloadButton) return;
|
|
const selectedCount = document.querySelectorAll('[data-object-select]:checked').length;
|
|
bulkDownloadButton.disabled = selectedCount === 0;
|
|
};
|
|
|
|
selectAllCheckbox?.addEventListener('change', (event) => {
|
|
const shouldSelect = Boolean(event.target?.checked);
|
|
|
|
const filesInView = visibleItems.filter(item => item.type === 'file');
|
|
|
|
filesInView.forEach(item => {
|
|
if (shouldSelect) {
|
|
selectedRows.set(item.data.key, item.data);
|
|
} else {
|
|
selectedRows.delete(item.data.key);
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[data-folder-select]').forEach(cb => {
|
|
cb.checked = shouldSelect;
|
|
});
|
|
|
|
document.querySelectorAll('[data-object-row]').forEach((row) => {
|
|
const checkbox = row.querySelector('[data-object-select]');
|
|
if (checkbox) {
|
|
checkbox.checked = shouldSelect;
|
|
}
|
|
});
|
|
|
|
updateBulkDeleteState();
|
|
setTimeout(updateBulkDownloadState, 0);
|
|
});
|
|
|
|
bulkDownloadButton?.addEventListener('click', async () => {
|
|
if (!bulkDownloadEndpoint) return;
|
|
const selected = Array.from(selectedRows.keys());
|
|
if (selected.length === 0) return;
|
|
|
|
bulkDownloadButton.disabled = true;
|
|
const originalHtml = bulkDownloadButton.innerHTML;
|
|
bulkDownloadButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Downloading...';
|
|
|
|
try {
|
|
const response = await fetch(bulkDownloadEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '',
|
|
},
|
|
body: JSON.stringify({ keys: selected }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.error || 'Download failed');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${document.getElementById('objects-drop-zone').dataset.bucket}-download.zip`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
a.remove();
|
|
} catch (error) {
|
|
showMessage({ title: 'Download Failed', body: error.message, variant: 'danger' });
|
|
} finally {
|
|
bulkDownloadButton.disabled = false;
|
|
bulkDownloadButton.innerHTML = originalHtml;
|
|
}
|
|
});
|
|
|
|
const replicationStatsContainer = document.getElementById('replication-stats-cards');
|
|
if (replicationStatsContainer) {
|
|
const statusEndpoint = replicationStatsContainer.dataset.statusEndpoint;
|
|
const syncedEl = replicationStatsContainer.querySelector('[data-stat="synced"]');
|
|
const pendingEl = replicationStatsContainer.querySelector('[data-stat="pending"]');
|
|
const orphanedEl = replicationStatsContainer.querySelector('[data-stat="orphaned"]');
|
|
const bytesEl = replicationStatsContainer.querySelector('[data-stat="bytes"]');
|
|
const lastSyncEl = document.getElementById('replication-last-sync');
|
|
const lastSyncTimeEl = document.querySelector('[data-stat="last-sync-time"]');
|
|
const lastSyncKeyEl = document.querySelector('[data-stat="last-sync-key"]');
|
|
const endpointWarning = document.getElementById('replication-endpoint-warning');
|
|
const endpointErrorEl = document.getElementById('replication-endpoint-error');
|
|
const statusAlert = document.getElementById('replication-status-alert');
|
|
const statusBadge = document.getElementById('replication-status-badge');
|
|
const statusText = document.getElementById('replication-status-text');
|
|
const pauseForm = document.getElementById('pause-replication-form');
|
|
|
|
const loadReplicationStats = async () => {
|
|
try {
|
|
const resp = await fetch(statusEndpoint);
|
|
if (!resp.ok) throw new Error('Failed to fetch stats');
|
|
const data = await resp.json();
|
|
|
|
// Handle endpoint health status
|
|
if (data.endpoint_healthy === false) {
|
|
// Show warning and hide success alert
|
|
if (endpointWarning) {
|
|
endpointWarning.classList.remove('d-none');
|
|
if (endpointErrorEl && data.endpoint_error) {
|
|
endpointErrorEl.textContent = data.endpoint_error + '. Replication is paused until the endpoint is available.';
|
|
}
|
|
}
|
|
if (statusAlert) statusAlert.classList.add('d-none');
|
|
|
|
// Update status badge to show "Paused" with warning styling
|
|
if (statusBadge) {
|
|
statusBadge.className = 'badge bg-warning-subtle text-warning px-3 py-2';
|
|
statusBadge.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
|
|
</svg>
|
|
<span>Paused (Endpoint Unavailable)</span>`;
|
|
}
|
|
|
|
// Hide the pause button since replication is effectively already paused
|
|
if (pauseForm) pauseForm.classList.add('d-none');
|
|
} else {
|
|
// Hide warning and show success alert
|
|
if (endpointWarning) endpointWarning.classList.add('d-none');
|
|
if (statusAlert) statusAlert.classList.remove('d-none');
|
|
|
|
// Restore status badge to show "Enabled"
|
|
if (statusBadge) {
|
|
statusBadge.className = 'badge bg-success-subtle text-success px-3 py-2';
|
|
statusBadge.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
<span>Enabled</span>`;
|
|
}
|
|
|
|
// Show the pause button
|
|
if (pauseForm) pauseForm.classList.remove('d-none');
|
|
}
|
|
|
|
if (syncedEl) syncedEl.textContent = data.objects_synced;
|
|
if (pendingEl) {
|
|
pendingEl.textContent = data.objects_pending;
|
|
if (data.objects_pending > 0) pendingEl.classList.add('text-warning');
|
|
}
|
|
if (orphanedEl) orphanedEl.textContent = data.objects_orphaned;
|
|
if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced);
|
|
|
|
if (data.last_sync_at && lastSyncEl) {
|
|
lastSyncEl.style.display = '';
|
|
const date = new Date(data.last_sync_at * 1000);
|
|
if (lastSyncTimeEl) lastSyncTimeEl.textContent = date.toLocaleString();
|
|
if (lastSyncKeyEl && data.last_sync_key) {
|
|
lastSyncKeyEl.innerHTML = ' — <code class="small">' + escapeHtml(data.last_sync_key) + '</code>';
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load replication stats:', err);
|
|
if (syncedEl) syncedEl.textContent = '—';
|
|
if (pendingEl) pendingEl.textContent = '—';
|
|
if (orphanedEl) orphanedEl.textContent = '—';
|
|
if (bytesEl) bytesEl.textContent = '—';
|
|
}
|
|
};
|
|
|
|
loadReplicationStats();
|
|
|
|
const refreshBtn = document.querySelector('[data-refresh-replication]');
|
|
refreshBtn?.addEventListener('click', () => {
|
|
|
|
if (syncedEl) syncedEl.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
if (pendingEl) pendingEl.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
if (orphanedEl) orphanedEl.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
if (bytesEl) bytesEl.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
loadReplicationStats();
|
|
loadReplicationFailures();
|
|
});
|
|
|
|
const failuresCard = document.getElementById('replication-failures-card');
|
|
const failuresBody = document.getElementById('replication-failures-body');
|
|
const failureCountBadge = document.getElementById('replication-failure-count');
|
|
const retryAllBtn = document.getElementById('retry-all-failures-btn');
|
|
const clearFailuresBtn = document.getElementById('clear-failures-btn');
|
|
const showMoreFailuresBtn = document.getElementById('show-more-failures');
|
|
const failuresPagination = document.getElementById('replication-failures-pagination');
|
|
const failuresShownCount = document.getElementById('failures-shown-count');
|
|
|
|
let failuresExpanded = false;
|
|
let currentFailures = [];
|
|
|
|
const loadReplicationFailures = async () => {
|
|
if (!failuresCard) return;
|
|
|
|
const endpoint = failuresCard.dataset.failuresEndpoint;
|
|
const limit = failuresExpanded ? 50 : 5;
|
|
|
|
try {
|
|
const resp = await fetch(`${endpoint}?limit=${limit}`);
|
|
if (!resp.ok) throw new Error('Failed to fetch failures');
|
|
const data = await resp.json();
|
|
|
|
currentFailures = data.failures;
|
|
const total = data.total;
|
|
|
|
if (total > 0) {
|
|
failuresCard.style.display = '';
|
|
failureCountBadge.textContent = total;
|
|
renderFailures(currentFailures);
|
|
|
|
if (total > 5 && !failuresExpanded) {
|
|
failuresPagination.style.display = '';
|
|
failuresShownCount.textContent = `Showing ${Math.min(5, total)} of ${total}`;
|
|
} else {
|
|
failuresPagination.style.display = 'none';
|
|
}
|
|
} else {
|
|
failuresCard.style.display = 'none';
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load replication failures:', err);
|
|
}
|
|
};
|
|
|
|
const renderFailures = (failures) => {
|
|
if (!failuresBody) return;
|
|
failuresBody.innerHTML = failures.map(f => `
|
|
<tr>
|
|
<td class="ps-3" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(f.object_key)}">
|
|
<code class="small">${escapeHtml(f.object_key)}</code>
|
|
</td>
|
|
<td class="small text-muted" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(f.error_message)}">
|
|
${escapeHtml(f.error_message)}
|
|
</td>
|
|
<td class="small text-muted">${new Date(f.timestamp * 1000).toLocaleString()}</td>
|
|
<td class="text-center"><span class="badge bg-secondary">${f.failure_count}</span></td>
|
|
<td class="text-end pe-3">
|
|
<button class="btn btn-sm btn-outline-primary py-0 px-2" onclick="retryFailure('${escapeHtml(f.object_key)}')" title="Retry">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
|
|
</svg>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="dismissFailure('${escapeHtml(f.object_key)}')" title="Dismiss">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
};
|
|
|
|
window.retryFailure = async (objectKey) => {
|
|
const endpoint = failuresCard.dataset.retryEndpoint.replace('__KEY__', encodeURIComponent(objectKey));
|
|
try {
|
|
const resp = await fetch(endpoint, { method: 'POST' });
|
|
if (resp.ok) {
|
|
loadReplicationFailures();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to retry:', err);
|
|
}
|
|
};
|
|
|
|
window.dismissFailure = async (objectKey) => {
|
|
const endpoint = failuresCard.dataset.dismissEndpoint.replace('__KEY__', encodeURIComponent(objectKey));
|
|
try {
|
|
const resp = await fetch(endpoint, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
loadReplicationFailures();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to dismiss:', err);
|
|
}
|
|
};
|
|
|
|
retryAllBtn?.addEventListener('click', async () => {
|
|
const endpoint = failuresCard.dataset.retryAllEndpoint;
|
|
try {
|
|
const resp = await fetch(endpoint, { method: 'POST' });
|
|
if (resp.ok) {
|
|
loadReplicationFailures();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to retry all:', err);
|
|
}
|
|
});
|
|
|
|
clearFailuresBtn?.addEventListener('click', async () => {
|
|
if (!confirm('Clear all failure records?')) return;
|
|
const endpoint = failuresCard.dataset.clearEndpoint;
|
|
try {
|
|
const resp = await fetch(endpoint, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
loadReplicationFailures();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to clear failures:', err);
|
|
}
|
|
});
|
|
|
|
showMoreFailuresBtn?.addEventListener('click', () => {
|
|
failuresExpanded = !failuresExpanded;
|
|
showMoreFailuresBtn.textContent = failuresExpanded ? 'Show less' : 'Show more...';
|
|
loadReplicationFailures();
|
|
});
|
|
|
|
loadReplicationFailures();
|
|
}
|
|
|
|
const algoAes256Radio = document.getElementById('algo_aes256');
|
|
const algoKmsRadio = document.getElementById('algo_kms');
|
|
const kmsKeySection = document.getElementById('kmsKeySection');
|
|
const encryptionForm = document.getElementById('encryptionForm');
|
|
const encryptionAction = document.getElementById('encryptionAction');
|
|
const disableEncryptionBtn = document.getElementById('disableEncryptionBtn');
|
|
|
|
const updateKmsKeyVisibility = () => {
|
|
if (!kmsKeySection) return;
|
|
const showKms = algoKmsRadio?.checked;
|
|
kmsKeySection.style.display = showKms ? '' : 'none';
|
|
};
|
|
|
|
algoAes256Radio?.addEventListener('change', updateKmsKeyVisibility);
|
|
algoKmsRadio?.addEventListener('change', updateKmsKeyVisibility);
|
|
|
|
disableEncryptionBtn?.addEventListener('click', () => {
|
|
if (encryptionAction && encryptionForm) {
|
|
if (confirm('Are you sure you want to disable default encryption? New objects will not be encrypted automatically.')) {
|
|
encryptionAction.value = 'disable';
|
|
encryptionForm.submit();
|
|
}
|
|
}
|
|
});
|
|
|
|
const targetBucketInput = document.getElementById('target_bucket');
|
|
const targetBucketFeedback = document.getElementById('target_bucket_feedback');
|
|
|
|
const validateBucketName = (name) => {
|
|
if (!name) return { valid: false, error: 'Bucket name is required' };
|
|
if (name.length < 3) return { valid: false, error: 'Bucket name must be at least 3 characters' };
|
|
if (name.length > 63) return { valid: false, error: 'Bucket name must be 63 characters or less' };
|
|
if (!/^[a-z0-9]/.test(name)) return { valid: false, error: 'Bucket name must start with a lowercase letter or number' };
|
|
if (!/[a-z0-9]$/.test(name)) return { valid: false, error: 'Bucket name must end with a lowercase letter or number' };
|
|
if (/[A-Z]/.test(name)) return { valid: false, error: 'Bucket name must not contain uppercase letters' };
|
|
if (/_/.test(name)) return { valid: false, error: 'Bucket name must not contain underscores' };
|
|
if (/\.\.|--/.test(name)) return { valid: false, error: 'Bucket name must not contain consecutive periods or hyphens' };
|
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return { valid: false, error: 'Bucket name must not be formatted as an IP address' };
|
|
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(name) && name.length > 2) return { valid: false, error: 'Bucket name contains invalid characters. Use only lowercase letters, numbers, hyphens, and periods.' };
|
|
return { valid: true, error: null };
|
|
};
|
|
|
|
const updateBucketNameValidation = () => {
|
|
if (!targetBucketInput || !targetBucketFeedback) return;
|
|
const name = targetBucketInput.value.trim();
|
|
if (!name) {
|
|
targetBucketInput.classList.remove('is-valid', 'is-invalid');
|
|
targetBucketFeedback.textContent = '';
|
|
return;
|
|
}
|
|
const result = validateBucketName(name);
|
|
targetBucketInput.classList.toggle('is-valid', result.valid);
|
|
targetBucketInput.classList.toggle('is-invalid', !result.valid);
|
|
targetBucketFeedback.textContent = result.error || '';
|
|
};
|
|
|
|
targetBucketInput?.addEventListener('input', updateBucketNameValidation);
|
|
targetBucketInput?.addEventListener('blur', updateBucketNameValidation);
|
|
|
|
const replicationForm = targetBucketInput?.closest('form');
|
|
replicationForm?.addEventListener('submit', (e) => {
|
|
const name = targetBucketInput.value.trim();
|
|
const result = validateBucketName(name);
|
|
if (!result.valid) {
|
|
e.preventDefault();
|
|
updateBucketNameValidation();
|
|
targetBucketInput.focus();
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const formatPolicyBtn = document.getElementById('formatPolicyBtn');
|
|
const policyValidationStatus = document.getElementById('policyValidationStatus');
|
|
const policyValidBadge = document.getElementById('policyValidBadge');
|
|
const policyInvalidBadge = document.getElementById('policyInvalidBadge');
|
|
const policyErrorDetail = document.getElementById('policyErrorDetail');
|
|
|
|
const validatePolicyJson = () => {
|
|
if (!policyTextarea || !policyValidationStatus) return;
|
|
const value = policyTextarea.value.trim();
|
|
if (!value) {
|
|
policyValidationStatus.classList.add('d-none');
|
|
policyErrorDetail?.classList.add('d-none');
|
|
return;
|
|
}
|
|
policyValidationStatus.classList.remove('d-none');
|
|
try {
|
|
JSON.parse(value);
|
|
policyValidBadge?.classList.remove('d-none');
|
|
policyInvalidBadge?.classList.add('d-none');
|
|
policyErrorDetail?.classList.add('d-none');
|
|
} catch (err) {
|
|
policyValidBadge?.classList.add('d-none');
|
|
policyInvalidBadge?.classList.remove('d-none');
|
|
if (policyErrorDetail) {
|
|
policyErrorDetail.textContent = err.message;
|
|
policyErrorDetail.classList.remove('d-none');
|
|
}
|
|
}
|
|
};
|
|
|
|
policyTextarea?.addEventListener('input', validatePolicyJson);
|
|
policyTextarea?.addEventListener('blur', validatePolicyJson);
|
|
|
|
formatPolicyBtn?.addEventListener('click', () => {
|
|
if (!policyTextarea) return;
|
|
const value = policyTextarea.value.trim();
|
|
if (!value) return;
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
policyTextarea.value = JSON.stringify(parsed, null, 2);
|
|
validatePolicyJson();
|
|
} catch (err) {
|
|
validatePolicyJson();
|
|
}
|
|
});
|
|
|
|
if (policyTextarea && policyPreset?.value === 'custom') {
|
|
validatePolicyJson();
|
|
}
|
|
|
|
const lifecycleCard = document.getElementById('lifecycle-rules-card');
|
|
const lifecycleUrl = lifecycleCard?.dataset.lifecycleUrl;
|
|
const lifecycleRulesBody = document.getElementById('lifecycle-rules-body');
|
|
const addLifecycleRuleModalEl = document.getElementById('addLifecycleRuleModal');
|
|
const addLifecycleRuleModal = addLifecycleRuleModalEl ? new bootstrap.Modal(addLifecycleRuleModalEl) : null;
|
|
let lifecycleRules = [];
|
|
|
|
const loadLifecycleRules = async () => {
|
|
if (!lifecycleUrl || !lifecycleRulesBody) return;
|
|
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>';
|
|
try {
|
|
const resp = await fetch(lifecycleUrl);
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to load lifecycle rules');
|
|
lifecycleRules = data.rules || [];
|
|
renderLifecycleRules();
|
|
} catch (err) {
|
|
lifecycleRulesBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
|
|
}
|
|
};
|
|
|
|
const renderLifecycleRules = () => {
|
|
if (!lifecycleRulesBody) return;
|
|
if (lifecycleRules.length === 0) {
|
|
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>';
|
|
return;
|
|
}
|
|
lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => {
|
|
const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-';
|
|
const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-';
|
|
const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary';
|
|
return `<tr>
|
|
<td><code class="small">${escapeHtml(rule.ID || '')}</code></td>
|
|
<td><code class="small">${escapeHtml(rule.Filter?.Prefix || '*')}</code></td>
|
|
<td><span class="badge ${statusClass}">${escapeHtml(rule.Status)}</span></td>
|
|
<td class="small">${expiration}</td>
|
|
<td class="small">${noncurrent}</td>
|
|
<td class="text-end">
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-secondary" onclick="editLifecycleRule(${idx})" title="Edit rule">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>
|
|
</button>
|
|
<button class="btn btn-outline-danger" onclick="deleteLifecycleRule(${idx})" title="Delete rule">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
};
|
|
|
|
window.editLifecycleRule = (idx) => {
|
|
const rule = lifecycleRules[idx];
|
|
if (!rule) return;
|
|
document.getElementById('lifecycleRuleId').value = rule.ID || '';
|
|
document.getElementById('lifecycleRuleStatus').value = rule.Status || 'Enabled';
|
|
document.getElementById('lifecycleRulePrefix').value = rule.Filter?.Prefix || '';
|
|
document.getElementById('lifecycleExpirationDays').value = rule.Expiration?.Days || '';
|
|
document.getElementById('lifecycleNoncurrentDays').value = rule.NoncurrentVersionExpiration?.NoncurrentDays || '';
|
|
document.getElementById('lifecycleAbortMpuDays').value = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation || '';
|
|
window.editingLifecycleIdx = idx;
|
|
addLifecycleRuleModal?.show();
|
|
};
|
|
|
|
window.editingLifecycleIdx = null;
|
|
|
|
window.deleteLifecycleRule = async (idx) => {
|
|
lifecycleRules.splice(idx, 1);
|
|
await saveLifecycleRules();
|
|
};
|
|
|
|
const saveLifecycleRules = async () => {
|
|
if (!lifecycleUrl) return;
|
|
try {
|
|
const resp = await fetch(lifecycleUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' },
|
|
body: JSON.stringify({ rules: lifecycleRules })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to save');
|
|
showMessage({ title: 'Lifecycle rules saved', body: 'Configuration updated successfully.', variant: 'success' });
|
|
renderLifecycleRules();
|
|
} catch (err) {
|
|
showMessage({ title: 'Save failed', body: err.message, variant: 'danger' });
|
|
}
|
|
};
|
|
|
|
document.getElementById('addLifecycleRuleConfirm')?.addEventListener('click', async () => {
|
|
const ruleId = document.getElementById('lifecycleRuleId')?.value?.trim();
|
|
const status = document.getElementById('lifecycleRuleStatus')?.value || 'Enabled';
|
|
const prefix = document.getElementById('lifecycleRulePrefix')?.value?.trim() || '';
|
|
const expDays = parseInt(document.getElementById('lifecycleExpirationDays')?.value) || 0;
|
|
const ncDays = parseInt(document.getElementById('lifecycleNoncurrentDays')?.value) || 0;
|
|
const abortDays = parseInt(document.getElementById('lifecycleAbortMpuDays')?.value) || 0;
|
|
if (!ruleId) { showMessage({ title: 'Validation error', body: 'Rule ID is required', variant: 'warning' }); return; }
|
|
if (expDays === 0 && ncDays === 0 && abortDays === 0) { showMessage({ title: 'Validation error', body: 'At least one action is required', variant: 'warning' }); return; }
|
|
const rule = { ID: ruleId, Status: status, Filter: { Prefix: prefix } };
|
|
if (expDays > 0) rule.Expiration = { Days: expDays };
|
|
if (ncDays > 0) rule.NoncurrentVersionExpiration = { NoncurrentDays: ncDays };
|
|
if (abortDays > 0) rule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: abortDays };
|
|
if (typeof window.editingLifecycleIdx === 'number' && window.editingLifecycleIdx !== null) {
|
|
lifecycleRules[window.editingLifecycleIdx] = rule;
|
|
window.editingLifecycleIdx = null;
|
|
} else {
|
|
lifecycleRules.push(rule);
|
|
}
|
|
await saveLifecycleRules();
|
|
addLifecycleRuleModal?.hide();
|
|
document.getElementById('lifecycleRuleId').value = '';
|
|
document.getElementById('lifecycleRulePrefix').value = '';
|
|
document.getElementById('lifecycleExpirationDays').value = '';
|
|
document.getElementById('lifecycleNoncurrentDays').value = '';
|
|
document.getElementById('lifecycleAbortMpuDays').value = '';
|
|
document.getElementById('lifecycleRuleStatus').value = 'Enabled';
|
|
});
|
|
|
|
const corsCard = document.getElementById('cors-rules-card');
|
|
const corsUrl = corsCard?.dataset.corsUrl;
|
|
const corsRulesBody = document.getElementById('cors-rules-body');
|
|
const addCorsRuleModalEl = document.getElementById('addCorsRuleModal');
|
|
const addCorsRuleModal = addCorsRuleModalEl ? new bootstrap.Modal(addCorsRuleModalEl) : null;
|
|
let corsRules = [];
|
|
|
|
const loadCorsRules = async () => {
|
|
if (!corsUrl || !corsRulesBody) return;
|
|
corsRulesBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>';
|
|
try {
|
|
const resp = await fetch(corsUrl);
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to load CORS rules');
|
|
corsRules = data.rules || [];
|
|
renderCorsRules();
|
|
} catch (err) {
|
|
corsRulesBody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
|
|
}
|
|
};
|
|
|
|
const renderCorsRules = () => {
|
|
if (!corsRulesBody) return;
|
|
if (corsRules.length === 0) {
|
|
corsRulesBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">No CORS rules configured</td></tr>';
|
|
return;
|
|
}
|
|
corsRulesBody.innerHTML = corsRules.map((rule, idx) => {
|
|
const origins = (rule.AllowedOrigins || []).map(o => `<code class="small">${escapeHtml(o)}</code>`).join(', ');
|
|
const methods = (rule.AllowedMethods || []).map(m => `<span class="badge bg-primary-subtle text-primary">${escapeHtml(m)}</span>`).join(' ');
|
|
const headers = (rule.AllowedHeaders || []).slice(0, 3).map(h => `<code class="small">${escapeHtml(h)}</code>`).join(', ');
|
|
return `<tr>
|
|
<td>${origins || '<span class="text-muted">None</span>'}</td>
|
|
<td>${methods || '<span class="text-muted">None</span>'}</td>
|
|
<td>${headers || '<span class="text-muted">*</span>'}</td>
|
|
<td>${rule.MaxAgeSeconds || '<span class="text-muted">-</span>'}</td>
|
|
<td class="text-end">
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-secondary" onclick="editCorsRule(${idx})" title="Edit rule">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>
|
|
</button>
|
|
<button class="btn btn-outline-danger" onclick="deleteCorsRule(${idx})" title="Delete rule">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
};
|
|
|
|
window.editCorsRule = (idx) => {
|
|
const rule = corsRules[idx];
|
|
if (!rule) return;
|
|
document.getElementById('corsAllowedOrigins').value = (rule.AllowedOrigins || []).join('\n');
|
|
document.getElementById('corsAllowedHeaders').value = (rule.AllowedHeaders || []).join('\n');
|
|
document.getElementById('corsExposeHeaders').value = (rule.ExposeHeaders || []).join('\n');
|
|
document.getElementById('corsMaxAge').value = rule.MaxAgeSeconds || '';
|
|
document.getElementById('corsMethodGet').checked = (rule.AllowedMethods || []).includes('GET');
|
|
document.getElementById('corsMethodPut').checked = (rule.AllowedMethods || []).includes('PUT');
|
|
document.getElementById('corsMethodPost').checked = (rule.AllowedMethods || []).includes('POST');
|
|
document.getElementById('corsMethodDelete').checked = (rule.AllowedMethods || []).includes('DELETE');
|
|
document.getElementById('corsMethodHead').checked = (rule.AllowedMethods || []).includes('HEAD');
|
|
window.editingCorsIdx = idx;
|
|
addCorsRuleModal?.show();
|
|
};
|
|
|
|
window.editingCorsIdx = null;
|
|
|
|
window.deleteCorsRule = async (idx) => {
|
|
corsRules.splice(idx, 1);
|
|
await saveCorsRules();
|
|
};
|
|
|
|
const saveCorsRules = async () => {
|
|
if (!corsUrl) return;
|
|
try {
|
|
const resp = await fetch(corsUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' },
|
|
body: JSON.stringify({ rules: corsRules })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to save');
|
|
showMessage({ title: 'CORS rules saved', body: 'Configuration updated successfully.', variant: 'success' });
|
|
renderCorsRules();
|
|
} catch (err) {
|
|
showMessage({ title: 'Save failed', body: err.message, variant: 'danger' });
|
|
}
|
|
};
|
|
|
|
document.getElementById('addCorsRuleConfirm')?.addEventListener('click', async () => {
|
|
const originsRaw = document.getElementById('corsAllowedOrigins')?.value?.trim() || '';
|
|
const origins = originsRaw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
const methods = [];
|
|
if (document.getElementById('corsMethodGet')?.checked) methods.push('GET');
|
|
if (document.getElementById('corsMethodPut')?.checked) methods.push('PUT');
|
|
if (document.getElementById('corsMethodPost')?.checked) methods.push('POST');
|
|
if (document.getElementById('corsMethodDelete')?.checked) methods.push('DELETE');
|
|
if (document.getElementById('corsMethodHead')?.checked) methods.push('HEAD');
|
|
const headersRaw = document.getElementById('corsAllowedHeaders')?.value?.trim() || '';
|
|
const headers = headersRaw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
const exposeRaw = document.getElementById('corsExposeHeaders')?.value?.trim() || '';
|
|
const expose = exposeRaw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
const maxAge = parseInt(document.getElementById('corsMaxAge')?.value) || 0;
|
|
if (origins.length === 0) { showMessage({ title: 'Validation error', body: 'At least one origin is required', variant: 'warning' }); return; }
|
|
if (methods.length === 0) { showMessage({ title: 'Validation error', body: 'At least one method is required', variant: 'warning' }); return; }
|
|
const rule = { AllowedOrigins: origins, AllowedMethods: methods };
|
|
if (headers.length > 0) rule.AllowedHeaders = headers;
|
|
if (expose.length > 0) rule.ExposeHeaders = expose;
|
|
if (maxAge > 0) rule.MaxAgeSeconds = maxAge;
|
|
if (typeof window.editingCorsIdx === 'number' && window.editingCorsIdx !== null) {
|
|
corsRules[window.editingCorsIdx] = rule;
|
|
window.editingCorsIdx = null;
|
|
} else {
|
|
corsRules.push(rule);
|
|
}
|
|
await saveCorsRules();
|
|
addCorsRuleModal?.hide();
|
|
document.getElementById('corsAllowedOrigins').value = '';
|
|
document.getElementById('corsAllowedHeaders').value = '';
|
|
document.getElementById('corsExposeHeaders').value = '';
|
|
document.getElementById('corsMaxAge').value = '';
|
|
document.getElementById('corsMethodGet').checked = false;
|
|
document.getElementById('corsMethodPut').checked = false;
|
|
document.getElementById('corsMethodPost').checked = false;
|
|
document.getElementById('corsMethodDelete').checked = false;
|
|
document.getElementById('corsMethodHead').checked = false;
|
|
});
|
|
|
|
const aclCard = document.getElementById('bucket-acl-card');
|
|
const aclUrl = aclCard?.dataset.aclUrl;
|
|
const aclOwnerEl = document.getElementById('acl-owner');
|
|
const aclGrantsList = document.getElementById('acl-grants-list');
|
|
const aclLoading = document.getElementById('acl-loading');
|
|
const aclContent = document.getElementById('acl-content');
|
|
const cannedAclSelect = document.getElementById('cannedAclSelect');
|
|
|
|
const loadAcl = async () => {
|
|
if (!aclUrl) return;
|
|
try {
|
|
const resp = await fetch(aclUrl);
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to load ACL');
|
|
if (aclOwnerEl) aclOwnerEl.textContent = data.owner || '-';
|
|
if (aclGrantsList) {
|
|
const grants = data.grants || [];
|
|
if (grants.length === 0) {
|
|
aclGrantsList.innerHTML = '<div class="list-group-item text-muted text-center py-2">No grants</div>';
|
|
} else {
|
|
aclGrantsList.innerHTML = grants.map(g => `<div class="list-group-item d-flex justify-content-between align-items-center"><code class="small">${escapeHtml(g.grantee)}</code><span class="badge bg-secondary">${escapeHtml(g.permission)}</span></div>`).join('');
|
|
}
|
|
}
|
|
if (aclLoading) aclLoading.classList.add('d-none');
|
|
if (aclContent) aclContent.classList.remove('d-none');
|
|
} catch (err) {
|
|
if (aclLoading) aclLoading.classList.add('d-none');
|
|
if (aclContent) aclContent.classList.remove('d-none');
|
|
if (aclGrantsList) aclGrantsList.innerHTML = `<div class="list-group-item text-danger text-center py-2">${escapeHtml(err.message)}</div>`;
|
|
}
|
|
};
|
|
|
|
cannedAclSelect?.addEventListener('change', async () => {
|
|
const canned = cannedAclSelect.value;
|
|
if (!canned || !aclUrl) return;
|
|
try {
|
|
const resp = await fetch(aclUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' },
|
|
body: JSON.stringify({ canned_acl: canned })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to set ACL');
|
|
showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' });
|
|
await loadAcl();
|
|
} catch (err) {
|
|
showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' });
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[data-set-acl]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const canned = btn.dataset.setAcl;
|
|
if (!canned || !aclUrl) return;
|
|
btn.disabled = true;
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
|
try {
|
|
const resp = await fetch(aclUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' },
|
|
body: JSON.stringify({ canned_acl: canned })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to set ACL');
|
|
showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' });
|
|
await loadAcl();
|
|
} catch (err) {
|
|
showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' });
|
|
} finally {
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function(e) {
|
|
const dropdown = e.target.closest('.dropdown');
|
|
const menu = dropdown?.querySelector('.dropdown-menu');
|
|
const btn = e.target;
|
|
if (!menu || !btn) return;
|
|
const btnRect = btn.getBoundingClientRect();
|
|
menu.style.position = 'fixed';
|
|
menu.style.top = (btnRect.bottom + 4) + 'px';
|
|
menu.style.left = 'auto';
|
|
menu.style.right = (window.innerWidth - btnRect.right) + 'px';
|
|
menu.style.transform = 'none';
|
|
});
|
|
|
|
const previewTagsPanel = document.getElementById('preview-tags');
|
|
const previewTagsList = document.getElementById('preview-tags-list');
|
|
const previewTagsEmpty = document.getElementById('preview-tags-empty');
|
|
const previewTagsCount = document.getElementById('preview-tags-count');
|
|
const previewTagsEditor = document.getElementById('preview-tags-editor');
|
|
const previewTagsInputs = document.getElementById('preview-tags-inputs');
|
|
const editTagsButton = document.getElementById('editTagsButton');
|
|
const addTagRow = document.getElementById('addTagRow');
|
|
const saveTagsButton = document.getElementById('saveTagsButton');
|
|
const cancelTagsButton = document.getElementById('cancelTagsButton');
|
|
let currentObjectTags = [];
|
|
let isEditingTags = false;
|
|
|
|
const loadObjectTags = async (row) => {
|
|
if (!row || !previewTagsPanel) return;
|
|
const tagsUrl = row.dataset.tagsUrl;
|
|
if (!tagsUrl) {
|
|
previewTagsPanel.classList.add('d-none');
|
|
return;
|
|
}
|
|
previewTagsPanel.classList.remove('d-none');
|
|
try {
|
|
const resp = await fetch(tagsUrl);
|
|
const data = await resp.json();
|
|
currentObjectTags = data.tags || [];
|
|
renderObjectTags();
|
|
} catch (err) {
|
|
currentObjectTags = [];
|
|
renderObjectTags();
|
|
}
|
|
};
|
|
|
|
const renderObjectTags = () => {
|
|
if (!previewTagsList || !previewTagsEmpty || !previewTagsCount) return;
|
|
previewTagsCount.textContent = currentObjectTags.length;
|
|
if (currentObjectTags.length === 0) {
|
|
previewTagsList.innerHTML = '';
|
|
previewTagsEmpty.classList.remove('d-none');
|
|
} else {
|
|
previewTagsEmpty.classList.add('d-none');
|
|
previewTagsList.innerHTML = currentObjectTags.map(t => `<span class="badge bg-info-subtle text-info">${escapeHtml(t.Key)}=${escapeHtml(t.Value)}</span>`).join('');
|
|
}
|
|
};
|
|
|
|
const renderTagEditor = () => {
|
|
if (!previewTagsInputs) return;
|
|
previewTagsInputs.innerHTML = currentObjectTags.map((t, idx) => `
|
|
<div class="input-group input-group-sm mb-1">
|
|
<input type="text" class="form-control" placeholder="Key" value="${escapeHtml(t.Key)}" data-tag-key="${idx}">
|
|
<input type="text" class="form-control" placeholder="Value" value="${escapeHtml(t.Value)}" data-tag-value="${idx}">
|
|
<button class="btn btn-outline-danger" type="button" onclick="removeTagRow(${idx})">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16"><path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/></svg>
|
|
</button>
|
|
</div>
|
|
`).join('');
|
|
};
|
|
|
|
window.removeTagRow = (idx) => {
|
|
currentObjectTags.splice(idx, 1);
|
|
renderTagEditor();
|
|
};
|
|
|
|
editTagsButton?.addEventListener('click', () => {
|
|
isEditingTags = true;
|
|
previewTagsList.classList.add('d-none');
|
|
previewTagsEmpty.classList.add('d-none');
|
|
previewTagsEditor?.classList.remove('d-none');
|
|
renderTagEditor();
|
|
});
|
|
|
|
cancelTagsButton?.addEventListener('click', () => {
|
|
isEditingTags = false;
|
|
previewTagsEditor?.classList.add('d-none');
|
|
previewTagsList.classList.remove('d-none');
|
|
renderObjectTags();
|
|
});
|
|
|
|
addTagRow?.addEventListener('click', () => {
|
|
if (currentObjectTags.length >= 10) {
|
|
showMessage({ title: 'Limit reached', body: 'Maximum 10 tags allowed per object.', variant: 'warning' });
|
|
return;
|
|
}
|
|
currentObjectTags.push({ Key: '', Value: '' });
|
|
renderTagEditor();
|
|
});
|
|
|
|
saveTagsButton?.addEventListener('click', async () => {
|
|
if (!activeRow) return;
|
|
const tagsUrl = activeRow.dataset.tagsUrl;
|
|
if (!tagsUrl) return;
|
|
const inputs = previewTagsInputs?.querySelectorAll('.input-group');
|
|
const newTags = [];
|
|
inputs?.forEach((group, idx) => {
|
|
const key = group.querySelector(`[data-tag-key="${idx}"]`)?.value?.trim() || '';
|
|
const value = group.querySelector(`[data-tag-value="${idx}"]`)?.value?.trim() || '';
|
|
if (key) newTags.push({ Key: key, Value: value });
|
|
});
|
|
try {
|
|
const resp = await fetch(tagsUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' },
|
|
body: JSON.stringify({ tags: newTags })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to save tags');
|
|
currentObjectTags = newTags;
|
|
isEditingTags = false;
|
|
previewTagsEditor?.classList.add('d-none');
|
|
previewTagsList.classList.remove('d-none');
|
|
renderObjectTags();
|
|
showMessage({ title: 'Tags saved', body: 'Object tags updated successfully.', variant: 'success' });
|
|
} catch (err) {
|
|
showMessage({ title: 'Save failed', body: err.message, variant: 'danger' });
|
|
}
|
|
});
|
|
|
|
const copyMoveModalEl = document.getElementById('copyMoveModal');
|
|
const copyMoveModal = copyMoveModalEl ? new bootstrap.Modal(copyMoveModalEl) : null;
|
|
const copyMoveActionLabel = document.getElementById('copyMoveActionLabel');
|
|
const copyMoveConfirmLabel = document.getElementById('copyMoveConfirmLabel');
|
|
const copyMoveSource = document.getElementById('copyMoveSource');
|
|
const copyMoveDestBucket = document.getElementById('copyMoveDestBucket');
|
|
const copyMoveDestKey = document.getElementById('copyMoveDestKey');
|
|
const copyMoveConfirm = document.getElementById('copyMoveConfirm');
|
|
const bucketsForCopyUrl = objectsContainer?.dataset.bucketsForCopyUrl;
|
|
let copyMoveAction = 'copy';
|
|
let copyMoveSourceKey = '';
|
|
|
|
window.openCopyMoveModal = async (action, key) => {
|
|
copyMoveAction = action;
|
|
copyMoveSourceKey = key;
|
|
if (copyMoveActionLabel) copyMoveActionLabel.textContent = action === 'move' ? 'Move' : 'Copy';
|
|
if (copyMoveConfirmLabel) copyMoveConfirmLabel.textContent = action === 'move' ? 'Move' : 'Copy';
|
|
if (copyMoveSource) copyMoveSource.textContent = key;
|
|
if (copyMoveDestKey) copyMoveDestKey.value = key;
|
|
if (copyMoveDestBucket) {
|
|
copyMoveDestBucket.innerHTML = '<option value="">Loading buckets...</option>';
|
|
try {
|
|
const resp = await fetch(bucketsForCopyUrl);
|
|
const data = await resp.json();
|
|
const buckets = data.buckets || [];
|
|
copyMoveDestBucket.innerHTML = buckets.map(b => `<option value="${escapeHtml(b)}">${escapeHtml(b)}</option>`).join('');
|
|
} catch {
|
|
copyMoveDestBucket.innerHTML = '<option value="">Failed to load buckets</option>';
|
|
}
|
|
}
|
|
copyMoveModal?.show();
|
|
};
|
|
|
|
copyMoveConfirm?.addEventListener('click', async () => {
|
|
const destBucket = copyMoveDestBucket?.value;
|
|
const destKey = copyMoveDestKey?.value?.trim();
|
|
if (!destBucket || !destKey) { showMessage({ title: 'Validation error', body: 'Destination bucket and key are required', variant: 'warning' }); return; }
|
|
const actionUrl = copyMoveAction === 'move'
|
|
? urlTemplates?.move?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/'))
|
|
: urlTemplates?.copy?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/'));
|
|
if (!actionUrl) { showMessage({ title: 'Error', body: 'Copy/move URL not configured', variant: 'danger' }); return; }
|
|
try {
|
|
const resp = await fetch(actionUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' },
|
|
body: JSON.stringify({ dest_bucket: destBucket, dest_key: destKey })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || `Failed to ${copyMoveAction} object`);
|
|
showMessage({ title: `Object ${copyMoveAction === 'move' ? 'moved' : 'copied'}`, body: `Successfully ${copyMoveAction === 'move' ? 'moved' : 'copied'} to ${destBucket}/${destKey}`, variant: 'success' });
|
|
copyMoveModal?.hide();
|
|
if (copyMoveAction === 'move') window.setTimeout(() => window.location.reload(), 500);
|
|
} catch (err) {
|
|
showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' });
|
|
}
|
|
});
|
|
|
|
const originalSelectRow = selectRow;
|
|
selectRow = (row) => {
|
|
originalSelectRow(row);
|
|
loadObjectTags(row);
|
|
};
|
|
|
|
if (lifecycleCard) loadLifecycleRules();
|
|
|
|
const lifecycleHistoryCard = document.getElementById('lifecycle-history-card');
|
|
const lifecycleHistoryBody = document.getElementById('lifecycle-history-body');
|
|
const lifecycleHistoryPagination = document.getElementById('lifecycle-history-pagination');
|
|
const showMoreHistoryBtn = document.getElementById('show-more-history');
|
|
const historyShownCount = document.getElementById('history-shown-count');
|
|
let historyExpanded = false;
|
|
|
|
const loadLifecycleHistory = async () => {
|
|
if (!lifecycleHistoryCard || !lifecycleHistoryBody) return;
|
|
|
|
const endpoint = lifecycleHistoryCard.dataset.historyEndpoint;
|
|
const limit = historyExpanded ? 50 : 5;
|
|
|
|
lifecycleHistoryBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>';
|
|
|
|
try {
|
|
const resp = await fetch(`${endpoint}?limit=${limit}`);
|
|
if (!resp.ok) throw new Error('Failed to fetch history');
|
|
const data = await resp.json();
|
|
|
|
if (!data.enabled) {
|
|
lifecycleHistoryBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Lifecycle enforcement is not enabled</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const executions = data.executions || [];
|
|
const total = data.total || 0;
|
|
|
|
if (executions.length === 0) {
|
|
lifecycleHistoryBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">No executions recorded yet</td></tr>';
|
|
lifecycleHistoryPagination.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
lifecycleHistoryBody.innerHTML = executions.map(e => {
|
|
const date = new Date(e.timestamp * 1000);
|
|
const hasErrors = e.errors && e.errors.length > 0;
|
|
const hasActivity = e.objects_deleted > 0 || e.versions_deleted > 0 || e.uploads_aborted > 0;
|
|
let statusBadge;
|
|
if (hasErrors) {
|
|
statusBadge = '<span class="badge bg-danger">Errors</span>';
|
|
} else if (hasActivity) {
|
|
statusBadge = '<span class="badge bg-success">Success</span>';
|
|
} else {
|
|
statusBadge = '<span class="badge bg-secondary">No action</span>';
|
|
}
|
|
const errorTooltip = hasErrors ? ` title="${escapeHtml(e.errors.join('; '))}"` : '';
|
|
return `<tr${errorTooltip}>
|
|
<td class="small">${date.toLocaleString()}</td>
|
|
<td class="text-center"><span class="badge bg-danger-subtle text-danger">${e.objects_deleted}</span></td>
|
|
<td class="text-center"><span class="badge bg-warning-subtle text-warning">${e.versions_deleted}</span></td>
|
|
<td class="text-center"><span class="badge bg-secondary">${e.uploads_aborted}</span></td>
|
|
<td class="text-center">${statusBadge}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
if (total > 5 && !historyExpanded) {
|
|
lifecycleHistoryPagination.style.display = '';
|
|
historyShownCount.textContent = `Showing ${Math.min(5, total)} of ${total}`;
|
|
} else {
|
|
lifecycleHistoryPagination.style.display = 'none';
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load lifecycle history:', err);
|
|
lifecycleHistoryBody.innerHTML = '<tr><td colspan="5" class="text-center text-danger py-4">Failed to load history</td></tr>';
|
|
}
|
|
};
|
|
|
|
showMoreHistoryBtn?.addEventListener('click', () => {
|
|
historyExpanded = !historyExpanded;
|
|
showMoreHistoryBtn.textContent = historyExpanded ? 'Show less' : 'Show more...';
|
|
loadLifecycleHistory();
|
|
});
|
|
|
|
if (lifecycleHistoryCard) loadLifecycleHistory();
|
|
|
|
if (corsCard) loadCorsRules();
|
|
if (aclCard) loadAcl();
|
|
</script>
|
|
{% endblock %}
|