This commit is contained in:
liosha84
2025-08-22 18:57:24 +03:00
parent bc18200640
commit fac4e2d4df
41 changed files with 3401 additions and 7 deletions
+12
View File
@@ -0,0 +1,12 @@
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.jambotronGroup.jambotron.JambotronApplication
Spring-Boot-Version: 3.5.0
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Build-Jdk-Spec: 24
Implementation-Title: jambotron
Implementation-Version: 0.0.1-SNAPSHOT
+3
View File
@@ -39,6 +39,9 @@ dependencies {
implementation 'org.springframework.ai:spring-ai-zhipuai-spring-boot-starter:1.0.0-M6'
implementation 'org.springframework:spring-mock:2.0.8'
// Apache Commons Exec for process execution
implementation 'org.apache.commons:commons-exec:1.3'
implementation 'com.google.guava:guava:33.4.8-jre'
implementation 'io.micrometer:micrometer-core:1.12.0'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
@@ -0,0 +1,196 @@
<div class="file-browser-container">
<!-- Main Content with Sidenav -->
<mat-sidenav-container class="sidenav-container">
<!-- Folder Tree Sidenav -->
<mat-sidenav #sidenav
mode="side"
[opened]="showTreeView"
class="tree-sidenav">
<app-file-system-tree-component
(folderSelected)="onFolderSelected($event)"
>
</app-file-system-tree-component>
</mat-sidenav>
<!-- Main Content -->
<mat-sidenav-content class="main-content">
<mat-card class="content-card">
<mat-card-header>
<mat-card-title class="card-title">
<div class="title-section">
<button mat-icon-button (click)="toggleView()"
[matTooltip]="showTreeView ? 'Hide Tree' : 'Show Tree'">
<mat-icon>{{ showTreeView ? 'view_list' : 'account_tree' }}</mat-icon>
</button>
<!-- Breadcrumb Navigation -->
<nav class="breadcrumb-nav" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<button mat-button (click)="navigateToPath(-1)" class="path-button">
<mat-icon>home</mat-icon>
Root
</button>
</li>
<li *ngFor="let segment of pathSegments; let i = index" class="breadcrumb-item">
<mat-icon>chevron_right</mat-icon>
<button mat-button (click)="navigateToPath(i)" class="path-button">
{{ segment }}
</button>
</li>
</ol>
</nav>
<div class="header-actions">
<!-- Search Bar -->
<!--<div class="search-container">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search files...</mat-label>
<input matInput
[(ngModel)]="searchQuery"
(input)="onSearchInput($event)"
placeholder="Enter filename or extension">
<mat-icon matSuffix>search</mat-icon>
<button *ngIf="searchQuery"
mat-icon-button
matSuffix
(click)="clearSearch()"
matTooltip="Clear search">
<mat-icon>clear</mat-icon>
</button>
</mat-form-field>
</div>-->
<input #fileInput type="file" multiple style="display: none"
(change)="onFilesSelected($event)">
<button mat-icon-button
color="primary"
[matTooltip]="'Upload Files'"
(click)="fileInput.click()">
<mat-icon>upload</mat-icon>
</button>
<button mat-icon-button
color="accent"
[matTooltip]="'New Folder'"
(click)="openCreateFolderDialog()">
<mat-icon>create_new_folder</mat-icon>
</button>
</div>
</div>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Progress Bar -->
<mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
<!-- Upload Progress -->
<div *ngIf="uploading" class="upload-progress">
<mat-progress-bar mode="determinate" [value]="uploadProgress"></mat-progress-bar>
<span class="progress-text">Uploading... {{uploadProgress}}%</span>
</div>
<!-- File Table -->
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="file-table">
<!-- Selection Column -->
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="masterToggle()"
[checked]="selection.size > 0 && isAllSelected()"
[indeterminate]="selection.size > 0 && !isAllSelected()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let file">
<mat-checkbox (change)="toggleSelection(file)"
[checked]="isSelected(file)">
</mat-checkbox>
</td>
</ng-container>
<!-- Icon Column -->
<ng-container matColumnDef="icon">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let file" class="icon-cell">
<mat-icon [class.folder-icon]="file.type === 'directory'">
{{ getFileIcon(file) }}
</mat-icon>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let file"
(dblclick)="onFileDoubleClick(file)"
class="name-cell"
[class.clickable]="file.type === 'directory'">
{{ file.name }}
</td>
</ng-container>
<!-- Size Column -->
<ng-container matColumnDef="size">
<th mat-header-cell *matHeaderCellDef>Size</th>
<td mat-cell *matCellDef="let file">
{{ file.type === 'directory' ? '-' : formatFileSize(file.size) }}
</td>
</ng-container>
<!-- Last Modified Column -->
<ng-container matColumnDef="lastModified">
<th mat-header-cell *matHeaderCellDef>Last Modified</th>
<td mat-cell *matCellDef="let file">
{{ file.lastModified | date:'short' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>
<button *ngIf="selection.size > 0"
mat-icon-button
color="warn"
(click)="deleteSelected()"
matTooltip="Delete Selected">
<mat-icon>delete</mat-icon>
</button>
</th>
<td mat-cell *matCellDef="let file">
<button *ngIf="file.type === 'file'"
mat-icon-button
(click)="downloadFile(file)"
matTooltip="Download">
<mat-icon>download</mat-icon>
</button>
<button mat-icon-button
(click)="deleteFile(file)"
color="warn"
matTooltip="Delete">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
<!-- Empty State -->
<div *ngIf="!loading && dataSource.data.length === 0" class="empty-state">
<mat-icon>{{ searchQuery ? 'search_off' : 'folder_open' }}</mat-icon>
<h3>{{ searchQuery ? 'No results found' : 'This folder is empty' }}</h3>
<p>{{ searchQuery ? 'Try adjusting your search terms' : 'Upload files or create folders to get started.' }}</p>
</div>
</mat-card-content>
</mat-card>
</mat-sidenav-content>
</mat-sidenav-container>
</div>
@@ -0,0 +1,407 @@
/* file-browser-with-tree.component.scss */
.file-browser-container {
height: calc(100vh - 172px);
display: flex;
flex-direction: column;
}
.browser-toolbar {
position: sticky;
top: 0;
//z-index: 1000;
.menu-button {
margin-right: 16px;
}
.toolbar-spacer {
flex: 1 1 auto;
}
mat-icon:first-of-type {
margin-right: 8px;
}
}
.header-actions{
display: flex;
}
.sidenav-container {
flex: 1;
height: calc(100vh - 64px);
}
.tree-sidenav {
width: 280px;
min-width: 250px;
max-width: 400px;
@media (max-width: 768px) {
width: 250px;
}
}
.main-content {
height: 100%;
overflow: unset;
}
.content-card {
margin: 0;
height: 100%;
border-radius: 0;
box-shadow: none;
.mat-card-header {
border-bottom: 1px solid #e0e0e0;
padding: 16px 24px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0;
.title-section {
display: flex;
align-items: center;
gap: 8px;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
@media (max-width: 768px) {
gap: 8px;
.mat-raised-button {
min-width: auto;
padding: 0 12px;
.mat-icon {
margin-right: 4px;
}
}
}
}
}
.mat-card-content {
padding: 16px 24px;
height: calc(100% - 80px);
overflow: auto;
}
}
.breadcrumb-nav {
//margin-bottom: 16px;
//padding: 8px 0;
//border-bottom: 1px solid #e0e0e0;
}
.breadcrumb {
display: flex;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
flex-wrap: wrap;
}
.breadcrumb-item {
display: flex;
align-items: center;
&:not(:first-child) {
margin-left: 4px;
}
}
.path-button {
min-width: auto !important;
padding: 0 8px !important;
font-size: 14px;
text-transform: none;
.mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.search-container {
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 8px;
.search-field {
flex: 1;
max-width: 400px;
}
}
.table-container {
width: 100%;
overflow-x: auto;
margin-top: 16px;
height: calc(100vh - 95px);
overflow-y: auto;
}
.file-table {
width: 100%;
.mat-header-cell {
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.mat-cell {
border-bottom: 1px solid #e0e0e0;
}
.mat-row:hover {
background-color: #f5f5f5;
}
}
.icon-cell {
width: 40px;
text-align: center;
.mat-icon {
color: #666;
font-size: 20px;
width: 20px;
height: 20px;
&.folder-icon {
color: #2196F3;
}
}
}
.name-cell {
font-weight: 500;
&.clickable {
cursor: pointer;
color: #1976D2;
&:hover {
background-color: rgba(25, 118, 210, 0.08);
}
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
.mat-icon {
font-size: 72px;
width: 72px;
height: 72px;
color: #ccc;
margin-bottom: 24px;
}
h3 {
margin: 16px 0 8px;
color: #333;
font-size: 20px;
}
p {
margin: 0;
font-size: 14px;
max-width: 400px;
margin: 0 auto;
}
}
.mat-progress-bar {
margin-bottom: 16px;
}
.upload-progress {
margin-bottom: 16px;
.progress-text {
display: block;
text-align: center;
font-size: 12px;
margin-top: 4px;
color: #666;
}
}
// Responsive design
@media (max-width: 1024px) {
.tree-sidenav {
width: 240px;
}
.content-card .mat-card-content {
padding: 12px 16px;
}
.card-title .header-actions {
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
}
@media (max-width: 768px) {
.browser-toolbar {
.mat-toolbar-row {
padding: 0 8px;
}
span:not(.toolbar-spacer) {
font-size: 16px;
}
}
.tree-sidenav {
width: 100%;
max-width: 100%;
}
.content-card {
.mat-card-header {
padding: 12px 16px;
}
.card-title {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.header-actions {
width: 100%;
justify-content: flex-start;
}
}
}
.file-table {
font-size: 14px;
.mat-header-cell,
.mat-cell {
padding: 8px 4px;
}
}
.breadcrumb {
font-size: 12px;
.path-button {
padding: 0 4px !important;
font-size: 12px;
}
}
.search-container {
.search-field {
max-width: 100%;
}
}
}
@media (max-width: 480px) {
.content-card .mat-card-content {
padding: 8px 12px;
}
.card-title .header-actions {
.mat-raised-button {
font-size: 12px;
padding: 0 8px;
height: 32px;
line-height: 32px;
}
}
.file-table {
.icon-cell {
width: 32px;
.mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
}
// Dark theme support
@media (prefers-color-scheme: dark) {
.content-card {
background-color: #303030;
color: white;
.mat-card-header {
border-bottom-color: #424242;
}
}
.breadcrumb-nav {
border-bottom-color: #424242;
}
.file-table {
.mat-cell {
border-bottom-color: #424242;
}
.mat-row:hover {
background-color: #424242;
}
}
.empty-state {
color: #aaa;
h3 {
color: #fff;
}
}
}
// Animation for smooth transitions
.mat-sidenav-content {
transition: margin-left 0.3s ease-in-out;
}
.mat-sidenav {
transition: width 0.3s ease-in-out;
}
// Custom scrollbar for webkit browsers
.main-content::-webkit-scrollbar {
width: 8px;
}
.main-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.main-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.main-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FileBrowserWithTreeComponent } from './file-browser-with-tree-component';
describe('FileBrowserWithTreeComponent', () => {
let component: FileBrowserWithTreeComponent;
let fixture: ComponentFixture<FileBrowserWithTreeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FileBrowserWithTreeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FileBrowserWithTreeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,375 @@
import {Component, CUSTOM_ELEMENTS_SCHEMA, OnInit, ViewChild} from '@angular/core';
import {DatePipe, NgForOf, NgIf} from '@angular/common';
import {
MatCell,
MatCellDef, MatColumnDef,
MatHeaderCell, MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef, MatTable, MatTableDataSource
} from '@angular/material/table';
import {MatButton, MatIconButton} from '@angular/material/button';
import {MatCheckbox} from '@angular/material/checkbox';
import {MatProgressBar} from '@angular/material/progress-bar';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {FormsModule} from '@angular/forms';
import {MatCard, MatCardContent, MatCardHeader, MatCardSubtitle, MatCardTitle} from '@angular/material/card';
import {MatSidenav, MatSidenavContainer, MatSidenavContent} from '@angular/material/sidenav';
import {MatToolbar} from '@angular/material/toolbar';
import {MatIcon} from '@angular/material/icon';
import {MatTooltip} from '@angular/material/tooltip';
import {MatDialog} from '@angular/material/dialog';
import {MatSnackBar} from '@angular/material/snack-bar';
import {FileBrowserTreeService, FileItem} from '../../services/file-browser-tree.service';
import {FileSystemTreeComponent} from '../file-system-tree-component/file-system-tree-component';
@Component({
selector: 'app-file-browser-with-tree-component',
imports: [
NgIf,
MatIcon,
MatHeaderRowDef,
MatRowDef,
MatRow,
MatHeaderRow,
MatTooltip,
MatIconButton,
MatCellDef,
MatCell,
MatHeaderCell,
MatColumnDef,
MatHeaderCellDef,
MatCheckbox,
MatTable,
MatProgressBar,
FormsModule,
NgForOf,
MatButton,
MatCardContent,
MatSidenavContent,
MatCard,
MatCardHeader,
MatCardTitle,
MatSidenav,
MatSidenavContainer,
DatePipe,
FileSystemTreeComponent,
],
templateUrl: './file-browser-with-tree-component.html',
styleUrl: './file-browser-with-tree-component.scss',
schemas:[CUSTOM_ELEMENTS_SCHEMA]
})
export class FileBrowserWithTreeComponent implements OnInit {
@ViewChild('sidenav') sidenav!: MatSidenav;
@ViewChild(FileSystemTreeComponent) folderTree!: FileSystemTreeComponent;
displayedColumns: string[] = ['select', 'icon', 'name', 'size', 'lastModified', 'actions'];
dataSource = new MatTableDataSource<FileItem>();
currentPath: string = '/';
pathSegments: string[] = [];
loading = false;
uploading = false;
uploadProgress = 0;
selection = new Set<FileItem>();
showTreeView = true;
searchQuery = '';
filteredFiles: FileItem[] = [];
constructor(
private fileService: FileBrowserTreeService,
private dialog: MatDialog,
private snackBar: MatSnackBar
) {}
ngOnInit(): void {
this.loadFiles(this.currentPath);
}
toggleView(): void {
this.showTreeView = !this.showTreeView;
if (this.showTreeView) {
this.sidenav.open();
} else {
this.sidenav.close();
}
}
onFolderSelected(path: string): void {
this.loadFiles(path);
}
loadFiles(path: string): void {
this.loading = true;
this.selection.clear();
this.clearSearch();
this.fileService.getFiles(path).subscribe({
next: (files) => {
this.filteredFiles = files;
this.dataSource.data = files;
this.currentPath = path;
this.updatePathSegments();
this.loading = false;
// Update tree selection
if (this.folderTree) {
//this.folderTree.expandToPath(path);
}
},
error: (error) => {
this.snackBar.open('Error loading files: ' + error.message, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
this.loading = false;
}
});
}
private updatePathSegments(): void {
this.pathSegments = this.currentPath === '/' ?
[] : this.currentPath.split('/').filter(segment => segment);
}
navigateToPath(index: number): void {
if (index === -1) {
this.loadFiles('/');
} else {
const path = '/' + this.pathSegments.slice(0, index + 1).join('/');
this.loadFiles(path);
}
}
onFileDoubleClick(file: FileItem): void {
if (file.type === 'directory') {
const newPath = this.currentPath === '/' ?
`/${file.name}` : `${this.currentPath}/${file.name}`;
this.loadFiles(newPath);
} else {
this.downloadFile(file);
}
}
onSearchInput(event: any): void {
const query = event.target.value.toLowerCase();
if (query) {
this.dataSource.data = this.filteredFiles.filter(file =>
file.name.toLowerCase().includes(query)
);
} else {
this.dataSource.data = this.filteredFiles;
}
}
clearSearch(): void {
this.searchQuery = '';
this.dataSource.data = this.filteredFiles;
}
onFilesSelected(event: any): void {
const files: FileList = event.target.files;
if (files.length === 0) return;
this.uploading = true;
this.uploadProgress = 0;
const uploadPromises = Array.from(files).map(file => {
return this.fileService.uploadFile(this.currentPath, file).toPromise();
});
Promise.all(uploadPromises).then(() => {
this.uploading = false;
this.uploadProgress = 0;
this.snackBar.open(`${files.length} file(s) uploaded successfully`, 'Close', {
duration: 3000
});
this.loadFiles(this.currentPath);
}).catch(error => {
this.uploading = false;
this.uploadProgress = 0;
this.snackBar.open('Upload failed: ' + error.message, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
});
// Simulate upload progress
const progressInterval = setInterval(() => {
this.uploadProgress += 10;
if (this.uploadProgress >= 100) {
clearInterval(progressInterval);
}
}, 200);
}
openCreateFolderDialog(): void {
const folderName = prompt('Enter folder name:');
if (folderName && folderName.trim()) {
this.fileService.createFolder(this.currentPath, folderName.trim()).subscribe({
next: () => {
this.snackBar.open('Folder created successfully', 'Close', {
duration: 3000
});
this.loadFiles(this.currentPath);
// Refresh tree view
if (this.folderTree) {
this.folderTree.refresh();
}
},
error: (error) => {
this.snackBar.open('Failed to create folder: ' + error.message, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
}
});
}
}
downloadFile(file: FileItem): void {
this.fileService.downloadFile(file.path).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
a.click();
window.URL.revokeObjectURL(url);
},
error: (error) => {
this.snackBar.open('Download failed: ' + error.message, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
}
});
}
deleteFile(file: FileItem): void {
if (confirm(`Are you sure you want to delete "${file.name}"?`)) {
this.fileService.deleteFile(file.path).subscribe({
next: () => {
this.snackBar.open('File deleted successfully', 'Close', {
duration: 3000
});
this.loadFiles(this.currentPath);
// Refresh tree view if folder was deleted
if (file.type === 'directory' && this.folderTree) {
this.folderTree.refresh();
}
},
error: (error) => {
this.snackBar.open('Delete failed: ' + error.message, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
}
});
}
}
deleteSelected(): void {
const selectedFiles = Array.from(this.selection);
if (selectedFiles.length === 0) return;
const message = `Are you sure you want to delete ${selectedFiles.length} selected item(s)?`;
if (confirm(message)) {
const deletePromises = selectedFiles.map(file =>
this.fileService.deleteFile(file.path).toPromise()
);
Promise.all(deletePromises).then(() => {
this.snackBar.open(`${selectedFiles.length} item(s) deleted successfully`, 'Close', {
duration: 3000
});
this.selection.clear();
this.loadFiles(this.currentPath);
// Refresh tree view if any folders were deleted
const hasFolder = selectedFiles.some(file => file.type === 'directory');
if (hasFolder && this.folderTree) {
this.folderTree.refresh();
}
}).catch(error => {
this.snackBar.open('Some deletions failed: ' + error.message, 'Close', {
duration: 5000,
panelClass: 'error-snackbar'
});
this.loadFiles(this.currentPath);
});
}
}
toggleSelection(file: FileItem): void {
if (this.selection.has(file)) {
this.selection.delete(file);
} else {
this.selection.add(file);
}
}
isSelected(file: FileItem): boolean {
return this.selection.has(file);
}
masterToggle(): void {
if (this.isAllSelected()) {
this.selection.clear();
} else {
this.dataSource.data.forEach(file => this.selection.add(file));
}
}
isAllSelected(): boolean {
return this.dataSource.data.length > 0 &&
this.dataSource.data.every(file => this.selection.has(file));
}
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
getFileIcon(file: FileItem): string {
if (file.type === 'directory') return 'folder';
const extension = file.name.split('.').pop()?.toLowerCase();
switch (extension) {
case 'pdf': return 'picture_as_pdf';
case 'doc':
case 'docx': return 'description';
case 'xls':
case 'xlsx': return 'grid_on';
case 'jpg':
case 'jpeg':
case 'png':
case 'gif': return 'image';
case 'mp4':
case 'avi':
case 'mov': return 'movie';
case 'mp3':
case 'wav': return 'audiotrack';
case 'zip':
case 'rar':
case '7z': return 'archive';
case 'txt':
case 'md': return 'text_fields';
case 'js':
case 'ts':
case 'html':
case 'css':
case 'json': return 'code';
default: return 'insert_drive_file';
}
}
}
@@ -0,0 +1,35 @@
<div class="file-tree-container">
<div class="tree-header">
<mat-icon>folder_open</mat-icon>
<span>Folders</span>
<button mat-icon-button (click)="refresh()" matTooltip="Refresh">
<mat-icon>refresh</mat-icon>
</button>
</div>
<mat-tree [dataSource]="dataSource" [treeControl]="treeControl" class="file-tree">
<!-- Directory nodes -->
<mat-tree-node *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding>
<button
mat-icon-button
[attr.aria-label]="'Toggle ' + node.item"
(click)="treeControl.toggle(node)"
[disabled]="node.isLoading()">
<mat-icon class="mat-icon-rtl-mirror">
{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</button>
<mat-icon class="folder-icon" [class.selected]="selectedNode === node">
{{ treeControl.isExpanded(node) ? 'folder_open' : 'folder' }}
</mat-icon>
<span class="node-label"
[class.selected]="selectedNode === node"
(click)="selectNode(node)">
{{ node.item }}
</span>
</mat-tree-node>
</mat-tree>
</div>
@@ -0,0 +1,81 @@
.file-tree-container {
//padding: 20px;
//max-width: 900px;
}
.tree-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
font-weight: 500;
color: #333;
mat-icon:first-child {
margin-right: 8px;
color: #1976d2;
}
span {
flex: 1;
}
}
.file-tree {
background: white;
// border: 1px solid #e0e0e0;
// border-radius: 4px;
//max-height: 600px;
overflow-y: auto;
overflow-x: auto;
width: max-content;
}
.folder-icon {
margin-right: 8px;
font-size: 18px;
width: 18px;
height: 18px;
color: #666;
}
.folder-icon {
color: #ff9800;
}
.folder-icon.selected {
color: #1976d2;
}
.node-label {
font-size: 14px;
margin-left: 4px;
cursor: pointer;
flex: 1;
padding: 2px 4px;
border-radius: 3px;
transition: background-color 0.2s;
}
.node-label:hover {
background-color: #f0f0f0;
}
.node-label.selected {
background-color: #e3f2fd;
color: #1976d2;
font-weight: 500;
}
.mat-tree-node {
min-height: 36px;
display: flex;
align-items: center;
padding: 2px 0;
}
.mat-tree-node:hover {
background-color: #fafafa;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FileSystemTreeComponent } from './file-system-tree-component';
describe('FileSystemTreeComponent', () => {
let component: FileSystemTreeComponent;
let fixture: ComponentFixture<FileSystemTreeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FileSystemTreeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FileSystemTreeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,237 @@
import {CollectionViewer, SelectionChange, DataSource} from '@angular/cdk/collections';
import {FlatTreeControl} from '@angular/cdk/tree';
import {
ChangeDetectionStrategy,
Component,
Injectable,
inject,
signal,
Output,
EventEmitter
} from '@angular/core';
import {BehaviorSubject, merge, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {MatProgressBarModule} from '@angular/material/progress-bar';
import {MatIconModule} from '@angular/material/icon';
import {MatButtonModule} from '@angular/material/button';
import {MatTreeModule} from '@angular/material/tree';
import {NgIf} from '@angular/common';
import {FileBrowserTreeService, FileItem} from '../../services/file-browser-tree.service';
import {MatTooltip} from '@angular/material/tooltip';
/** Flat node with expandable and level information */
class DynamicFlatNode {
constructor(
public item: string,
public level = 1,
public expandable = false,
public isLoading = signal(false),
public fileItem: FileItem,
) {}
}
/**
* Database for dynamic data. When expanding a node in the tree, the data source will need to fetch
* the descendants data from the database.
*/
@Injectable({providedIn: 'root'})
export class DynamicDatabase {
rootNodes:FileItem[] = [];
constructor(private fileBrowserService: FileBrowserTreeService) {
this.getFileItems('/').subscribe(
value => {
console.log('value', value);
this.rootNodes = value;
},
error => {
console.error('Error loading files:', error);
}
)
}
getFileItems(path:string):Observable<FileItem[]> {
return this.fileBrowserService.getFoldersOnly(path);
}
/** Initial data from database */
initialData(): DynamicFlatNode[] {
return this.rootNodes.map(fileItem => new DynamicFlatNode(fileItem.name, 0, true, signal(false), fileItem));
//return this.rootLevelNodes.map(name => new DynamicFlatNode(name, 0, true));
}
isExpandable(fileItem: FileItem): boolean {
return fileItem.type === "directory"; //this.dataMap.has(node);
}
}
/**
* File database, it can build a tree structured Json object from string.
* Each node in Json object represents a file or a directory. For a file, it has filename and type.
* For a directory, it has filename and children (a list of files or directories).
* The input will be a json object string, and the output is a list of `FileNode` with nested
* structure.
*/
export class DynamicDataSource implements DataSource<DynamicFlatNode> {
dataChange = new BehaviorSubject<DynamicFlatNode[]>([]);
get data(): DynamicFlatNode[] {
return this.dataChange.value;
}
set data(value: DynamicFlatNode[]) {
this._treeControl.dataNodes = value;
this.dataChange.next(value);
}
constructor(
private _treeControl: FlatTreeControl<DynamicFlatNode>,
private _database: DynamicDatabase,
) {}
connect(collectionViewer: CollectionViewer): Observable<DynamicFlatNode[]> {
this._treeControl.expansionModel.changed.subscribe(change => {
if (
(change as SelectionChange<DynamicFlatNode>).added ||
(change as SelectionChange<DynamicFlatNode>).removed
) {
this.handleTreeControl(change as SelectionChange<DynamicFlatNode>);
}
});
return merge(collectionViewer.viewChange, this.dataChange).pipe(map(() => this.data));
}
disconnect(collectionViewer: CollectionViewer): void {}
/** Handle expand/collapse behaviors */
handleTreeControl(change: SelectionChange<DynamicFlatNode>) {
if (change.added) {
change.added.forEach(node => this.toggleNode(node, true));
}
if (change.removed) {
change.removed
.slice()
.reverse()
.forEach(node => this.toggleNode(node, false));
}
}
/**
* Toggle the node, remove from display list
*/
toggleNode(node: DynamicFlatNode, expand: boolean) {
const index = this.data.indexOf(node);
this._database.getFileItems(node.fileItem.path).subscribe(value => {
console.log('value', value);
node.isLoading.set(true);
const children = value;
if (!children || index < 0) {
// If no children, or cannot find the node, no op
return;
}
if (expand) {
const nodes = children.map(
fileItem => new DynamicFlatNode(fileItem.name, node.level + 1, this._database.isExpandable(fileItem), signal(false), fileItem,),
);
this.data.splice(index + 1, 0, ...nodes);
} else {
let count = 0;
for (
let i = index + 1;
i < this.data.length && this.data[i].level > node.level;
i++, count++
) {}
this.data.splice(index + 1, count);
}
// notify the change
this.dataChange.next(this.data);
node.isLoading.set(false);
});
// setTimeout(() => {
//
// }, 1000);
}
}
/**
* @title Tree with dynamic data
*/
@Component({
selector: 'app-file-system-tree-component',
templateUrl: 'file-system-tree-component.html',
styleUrl: 'file-system-tree-component.scss',
imports: [
MatTreeModule,
MatButtonModule,
MatIconModule,
MatProgressBarModule,
MatTooltip
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FileSystemTreeComponent {
database = inject(DynamicDatabase);
treeControl: FlatTreeControl<DynamicFlatNode>;
dataSource: DynamicDataSource;
selectedNode: DynamicFlatNode | null = null;
@Output() folderSelected = new EventEmitter<string>();
constructor() {
this.treeControl = new FlatTreeControl<DynamicFlatNode>(this.getLevel, this.isExpandable);
this.dataSource = new DynamicDataSource(this.treeControl, this.database);
// Initialize root data
this.refresh();
}
getLevel = (node: DynamicFlatNode) => node.level;
isExpandable = (node: DynamicFlatNode) => node.expandable;
hasChild = (_: number, _nodeData: DynamicFlatNode) => _nodeData.expandable;
selectNode(node: DynamicFlatNode|undefined): void {
if(node !== undefined){
this.selectedNode = node;
this.folderSelected.emit(node.fileItem.path);
}
else {
this.selectedNode = null;
this.folderSelected.emit('/');
}
}
refresh(): void {
this.selectNode(undefined);
this.treeControl.collapseAll();
this.database.getFileItems('/').subscribe(value => {
this.dataSource.data = value.map(fileItem =>
new DynamicFlatNode(
fileItem.name,
0,
fileItem.type === "directory",
signal(false),
fileItem
)
);
});
}
}
@@ -1,6 +1,13 @@
<p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p><p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p>
<p>admin-welcome.component works!</p>
@@ -2,12 +2,21 @@ import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {AdminComponent} from './admin.component/admin.component';
import {adminRouting} from './admin.routing';
import {MatTreeModule} from '@angular/material/tree';
import {MatIconModule} from '@angular/material/icon';
import {MatButtonModule} from '@angular/material/button';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
@NgModule({
declarations: [
],
imports: [adminRouting, AdminComponent],
imports: [adminRouting, AdminComponent,
MatTreeModule,
MatIconModule,
MatButtonModule,
MatProgressSpinnerModule
],
exports: [RouterModule],
schemas:[CUSTOM_ELEMENTS_SCHEMA]
})
@@ -28,6 +28,11 @@ export const ADMIN_ROUTES: Routes = [
loadComponent: () => import('../admin-module/users.component/users.component').then((c) => c.UsersComponent),
data:{title: 'Users', icon:'', nav:true}
},
{
path: 'hard-drive-browser',
loadComponent: () => import('../admin-module/hard-drive-browser.component/hard-drive-browser.component').then((c)=> c.HardDriveBrowserComponent),
data:{title: 'Hard Drive', icon:'hard_drive', nav:true}
},
{
path:"data-base",
loadComponent: () => import('../admin-module/data-base.component/data-base.component').then((c) => c.DataBaseComponent),
@@ -0,0 +1 @@
<app-file-browser-with-tree-component></app-file-browser-with-tree-component>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HardDriveBrowserComponent } from './hard-drive-browser.component';
describe('HardDriveBrowserComponent', () => {
let component: HardDriveBrowserComponent;
let fixture: ComponentFixture<HardDriveBrowserComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HardDriveBrowserComponent]
})
.compileComponents();
fixture = TestBed.createComponent(HardDriveBrowserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import {
FileBrowserWithTreeComponent
} from '../../../components/file-browser-with-tree-component/file-browser-with-tree-component';
@Component({
selector: 'app-hard-drive-browser.component',
imports: [
FileBrowserWithTreeComponent
],
templateUrl: './hard-drive-browser.component.html',
styleUrl: './hard-drive-browser.component.scss'
})
export class HardDriveBrowserComponent {
}
@@ -19,7 +19,7 @@ import {
MatDialogRef,
MatDialogTitle
} from '@angular/material/dialog';
import {MatFormField, MatInput, MatLabel, MatSuffix} from '@angular/material/input';
import {MatError, MatFormField, MatInput, MatLabel, MatSuffix} from '@angular/material/input';
import {MatIcon} from '@angular/material/icon';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DialogSignupData} from '../main.component';
@@ -41,6 +41,7 @@ import {ErrorStateMatcher} from '@angular/material/core';
MatSuffix,
MatFormField,
ReactiveFormsModule,
MatError,
],
templateUrl: './dialog-signup.component.html',
@@ -0,0 +1,109 @@
<div class="docker-management">
<h2>Docker Container Management</h2>
<!-- Container List -->
<div class="containers-section">
<h3>Running Containers</h3>
<button (click)="refreshContainers()" class="btn btn-secondary">Refresh</button>
<div class="container-list" *ngIf="containers.length > 0; else noContainers">
<div class="container-item" *ngFor="let container of containers">
<div class="container-info">
<strong>{{container.name}}</strong>
<span class="image">{{container.image}}</span>
<span class="status" [class.running]="container.status.includes('Up')">
{{container.status}}
</span>
</div>
<div class="container-actions">
<button (click)="stopContainer(container.name)"
[disabled]="!container.status.includes('Up')"
class="btn btn-warning">Stop</button>
<button (click)="startContainer(container.name)"
[disabled]="container.status.includes('Up')"
class="btn btn-success">Start</button>
<button (click)="removeContainer(container.name)"
class="btn btn-danger">Remove</button>
<button (click)="viewLogs(container.name)"
class="btn btn-info">Logs</button>
</div>
</div>
</div>
<ng-template #noContainers>
<p>No containers found</p>
</ng-template>
</div>
<!-- Run New Container -->
<div class="run-container-section">
<h3>Run New Container</h3>
<form [formGroup]="runContainerForm" (ngSubmit)="runContainer()">
<div class="form-group">
<label>Image Name:</label>
<input type="text" formControlName="imageName"
placeholder="e.g., nginx:latest" class="form-control">
</div>
<div class="form-group">
<label>Container Name:</label>
<input type="text" formControlName="containerName"
placeholder="Optional container name" class="form-control">
</div>
<div class="form-group">
<label>Ports:</label>
<input type="text" formControlName="ports"
placeholder="e.g., 8080:80" class="form-control">
</div>
<div class="form-group">
<label>Environment Variables:</label>
<input type="text" formControlName="environmentVars"
placeholder="e.g., -e NODE_ENV=production" class="form-control">
</div>
<button type="submit" [disabled]="runContainerForm.invalid || loading"
class="btn btn-primary">
{{loading ? 'Starting...' : 'Run Container'}}
</button>
</form>
</div>
<!-- Execute Command -->
<div class="execute-command-section">
<h3>Execute Docker Command</h3>
<form [formGroup]="commandForm" (ngSubmit)="executeCommand()">
<div class="form-group">
<label>Docker Command:</label>
<input type="text" formControlName="command"
placeholder="e.g., docker ps -a" class="form-control">
</div>
<button type="submit" [disabled]="commandForm.invalid || loading"
class="btn btn-primary">Execute</button>
</form>
</div>
<!-- Command Output -->
<div class="output-section" *ngIf="lastCommandResult">
<h3>Command Output</h3>
<div class="output-box"
[class.success]="lastCommandResult.success"
[class.error]="!lastCommandResult.success">
<pre>{{getOutputText(lastCommandResult.output)}}</pre>
</div>
</div>
<!-- Container Logs Modal -->
<div class="logs-modal" *ngIf="selectedContainerLogs">
<div class="modal-content">
<div class="modal-header">
<h3>Container Logs: {{selectedContainer}}</h3>
<button (click)="closeLogs()" class="close-btn">&times;</button>
</div>
<div class="modal-body">
<pre class="logs-content">{{selectedContainerLogs.join('\n')}}</pre>
</div>
</div>
</div>
</div>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DockerManagementComponent } from './docker-management-component';
describe('DockerManagementComponent', () => {
let component: DockerManagementComponent;
let fixture: ComponentFixture<DockerManagementComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DockerManagementComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DockerManagementComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,171 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {NgForOf, NgIf} from '@angular/common';
import {Subject, takeUntil} from 'rxjs';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ContainerInfo, DockerResult, DockerService} from '../docker.service';
@Component({
selector: 'app-docker-management-component',
imports: [
NgIf,
ReactiveFormsModule,
NgForOf
],
templateUrl: './docker-management-component.html',
styleUrl: './docker-management-component.scss'
})
export class DockerManagementComponent implements OnInit, OnDestroy {
containers: ContainerInfo[] = [];
runContainerForm: FormGroup;
commandForm: FormGroup;
lastCommandResult: DockerResult | null = null;
selectedContainer: string = '';
selectedContainerLogs: string[] | null = null;
loading = false;
private destroy$ = new Subject<void>();
constructor(
private dockerService: DockerService,
private fb: FormBuilder
) {
this.runContainerForm = this.fb.group({
imageName: ['', Validators.required],
containerName: [''],
ports: [''],
environmentVars: ['']
});
this.commandForm = this.fb.group({
command: ['docker ps', Validators.required]
});
}
ngOnInit() {
// Subscribe to containers updates
this.dockerService.containers$
.pipe(takeUntil(this.destroy$))
.subscribe(containers => {
this.containers = containers;
});
// Initial load
this.refreshContainers();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
refreshContainers() {
this.dockerService.refreshContainers();
}
runContainer() {
if (this.runContainerForm.valid) {
this.loading = true;
const formValue = this.runContainerForm.value;
this.dockerService.runContainer(formValue)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.lastCommandResult = result;
this.loading = false;
if (result.success) {
this.runContainerForm.reset();
}
},
error: (error) => {
console.error('Error running container:', error);
this.loading = false;
}
});
}
}
stopContainer(containerName: string) {
this.dockerService.stopContainer(containerName)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.lastCommandResult = result;
},
error: (error) => {
console.error('Error stopping container:', error);
}
});
}
startContainer(containerName: string) {
this.dockerService.startContainer(containerName)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.lastCommandResult = result;
},
error: (error) => {
console.error('Error starting container:', error);
}
});
}
removeContainer(containerName: string) {
if (confirm(`Are you sure you want to remove container "${containerName}"?`)) {
this.dockerService.removeContainer(containerName)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.lastCommandResult = result;
},
error: (error) => {
console.error('Error removing container:', error);
}
});
}
}
viewLogs(containerName: string) {
this.selectedContainer = containerName;
this.dockerService.getContainerLogs(containerName, 100)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (logs) => {
this.selectedContainerLogs = logs;
},
error: (error) => {
console.error('Error getting logs:', error);
}
});
}
closeLogs() {
this.selectedContainerLogs = null;
this.selectedContainer = '';
}
executeCommand() {
if (this.commandForm.valid) {
this.loading = true;
const command = this.commandForm.get('command')?.value;
this.dockerService.executeCommand(command)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.lastCommandResult = result;
this.loading = false;
},
error: (error) => {
console.error('Error executing command:', error);
this.loading = false;
}
});
}
}
getOutputText(output: string[] | string): string {
return Array.isArray(output) ? output.join('\n') : output;
}
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { DockerService } from './docker.service';
describe('DockerService', () => {
let service: DockerService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(DockerService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -0,0 +1,471 @@
// docker.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import {GlobalConstants} from '../../global-constants';
// Interfaces for type safety
export interface DockerResult {
success: boolean;
exitCode?: number;
output: string[] | string;
command?: string;
error?: string;
}
export interface ContainerRunRequest {
imageName: string;
containerName?: string;
ports?: string;
environmentVars?: string;
}
export interface ContainerInfo {
containerId: string;
name: string;
image: string;
status: string;
ports: string;
created: string;
}
export interface CommandRequest {
command: string;
}
export interface ExecCommandRequest {
command: string;
}
@Injectable({
providedIn: 'root'
})
export class DockerService {
baseUrl = `${GlobalConstants.API_URL}/user/docker`;
private readonly httpOptions = {
headers: new HttpHeaders({
'Content-Type': 'application/json'
})
};
// Observable for real-time container status updates
private containersSubject = new BehaviorSubject<ContainerInfo[]>([]);
public containers$ = this.containersSubject.asObservable();
constructor(private http: HttpClient) {
// Auto-refresh containers every 30 seconds
//this.startAutoRefresh();
}
/**
* Execute arbitrary Docker command
*/
executeCommand(command: string): Observable<DockerResult> {
const request: CommandRequest = { command };
return this.http.post<DockerResult>(`${this.baseUrl}/execute`, request, this.httpOptions)
.pipe(
map(response => this.normalizeDockerResult(response)),
catchError(this.handleError)
);
}
/**
* Execute Docker command asynchronously
*/
executeCommandAsync(command: string): Observable<DockerResult> {
const request: CommandRequest = { command };
return this.http.post<DockerResult>(`${this.baseUrl}/execute/async`, request, this.httpOptions)
.pipe(
map(response => this.normalizeDockerResult(response)),
catchError(this.handleError)
);
}
/**
* Run a Docker container
*/
runContainer(request: ContainerRunRequest): Observable<DockerResult> {
return this.http.post<DockerResult>(`${this.baseUrl}/container/run`, request, this.httpOptions)
.pipe(
map(response => {
// Refresh containers list after successful container creation
if (response.success) {
this.refreshContainers();
}
return this.normalizeDockerResult(response);
}),
catchError(this.handleError)
);
}
/**
* Stop a Docker container
*/
stopContainer(containerName: string): Observable<DockerResult> {
return this.http.post<DockerResult>(`${this.baseUrl}/container/${containerName}/stop`, {}, this.httpOptions)
.pipe(
map(response => {
// Refresh containers list after stopping
if (response.success) {
this.refreshContainers();
}
return this.normalizeDockerResult(response);
}),
catchError(this.handleError)
);
}
/**
* Start a Docker container
*/
startContainer(containerName: string): Observable<DockerResult> {
return this.executeCommand(`docker start ${containerName}`)
.pipe(
map(response => {
if (response.success) {
this.refreshContainers();
}
return response;
})
);
}
/**
* Remove a Docker container
*/
removeContainer(containerName: string, force: boolean = false): Observable<DockerResult> {
const command = force ? `docker rm -f ${containerName}` : `docker rm ${containerName}`;
return this.executeCommand(command)
.pipe(
map(response => {
if (response.success) {
this.refreshContainers();
}
return response;
})
);
}
/**
* Get list of all containers
*/
listContainers(): Observable<ContainerInfo[]> {
return this.http.get<{success: boolean, containers: string[]}>(`${this.baseUrl}/containers`)
.pipe(
map(response => {
if (response.success && response.containers) {
const containers = this.parseContainerList(response.containers);
this.containersSubject.next(containers);
return containers;
}
return [];
}),
catchError(this.handleError)
);
}
/**
* Get running containers only
*/
listRunningContainers(): Observable<ContainerInfo[]> {
return this.listContainers().pipe(
map(containers => containers.filter(c => c.status.includes('Up')))
);
}
/**
* Get container logs
*/
getContainerLogs(containerName: string, lines: number = 100): Observable<string[]> {
const params = new HttpParams().set('lines', lines.toString());
return this.http.get<{success: boolean, logs: string[]}>(`${this.baseUrl}/container/${containerName}/logs`, { params })
.pipe(
map(response => response.success ? response.logs : []),
catchError(this.handleError)
);
}
/**
* Execute command inside container
*/
execInContainer(containerName: string, command: string): Observable<DockerResult> {
const request: ExecCommandRequest = { command };
return this.http.post<DockerResult>(`${this.baseUrl}/container/${containerName}/exec`, request, this.httpOptions)
.pipe(
map(response => this.normalizeDockerResult(response)),
catchError(this.handleError)
);
}
/**
* Build Docker image
*/
buildImage(imageName: string, dockerfilePath: string = '.', tag?: string): Observable<DockerResult> {
const fullTag = tag ? `${imageName}:${tag}` : imageName;
const command = `docker build -t ${fullTag} ${dockerfilePath}`;
return this.executeCommand(command);
}
/**
* Pull Docker image
*/
pullImage(imageName: string, tag: string = 'latest'): Observable<DockerResult> {
const command = `docker pull ${imageName}:${tag}`;
return this.executeCommand(command);
}
/**
* Get Docker images
*/
listImages(): Observable<DockerResult> {
return this.executeCommand('docker images');
}
/**
* Remove Docker image
*/
removeImage(imageName: string, force: boolean = false): Observable<DockerResult> {
const command = force ? `docker rmi -f ${imageName}` : `docker rmi ${imageName}`;
return this.executeCommand(command);
}
/**
* Docker system info
*/
getSystemInfo(): Observable<DockerResult> {
return this.executeCommand('docker system info');
}
/**
* Docker version info
*/
getVersion(): Observable<DockerResult> {
return this.executeCommand('docker --version');
}
/**
* Check if Docker is running
*/
isDockerRunning(): Observable<boolean> {
return this.executeCommand('docker ps').pipe(
map(result => result.success),
catchError(() => [false])
);
}
/**
* Run Docker Compose up
*/
dockerComposeUp(composePath: string = 'docker-compose.yml'): Observable<DockerResult> {
return this.executeCommand(`docker-compose -f ${composePath} up -d`);
}
/**
* Run Docker Compose down
*/
dockerComposeDown(composePath: string = 'docker-compose.yml'): Observable<DockerResult> {
return this.executeCommand(`docker-compose -f ${composePath} down`);
}
/**
* Get Docker Compose services status
*/
dockerComposePs(composePath: string = 'docker-compose.yml'): Observable<DockerResult> {
return this.executeCommand(`docker-compose -f ${composePath} ps`);
}
/**
* Refresh containers list
*/
refreshContainers(): void {
this.listContainers().subscribe();
}
/**
* Start auto-refresh of containers
*/
private startAutoRefresh(): void {
setInterval(() => {
this.refreshContainers();
}, 30000); // Refresh every 30 seconds
}
/**
* Parse container list output into structured data
*/
private parseContainerList(containerLines: string[]): ContainerInfo[] {
const containers: ContainerInfo[] = [];
// Skip header line
const dataLines = containerLines.slice(1);
dataLines.forEach(line => {
if (line.trim()) {
const parts = line.split(/\s{2,}/); // Split by 2 or more spaces
if (parts.length >= 6) {
containers.push({
containerId: parts[0],
image: parts[1],
name: parts[6] || parts[parts.length - 1],
status: parts[4],
ports: parts[5] || '',
created: parts[3]
});
}
}
});
return containers;
}
/**
* Normalize Docker result response
*/
private normalizeDockerResult(response: any): DockerResult {
return {
success: response.success || false,
exitCode: response.exitCode,
output: response.output || response.logs || [],
command: response.command,
error: response.error
};
}
/**
* Handle HTTP errors
*/
private handleError = (error: any): Observable<never> => {
console.error('Docker Service Error:', error);
let errorMessage = 'An unknown error occurred';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = error.error.message;
} else {
// Server-side error
errorMessage = error.error?.message || error.message || `Error Code: ${error.status}`;
}
return throwError(() => new Error(errorMessage));
}
}
// docker-models.ts - Additional models file
export interface DockerStats {
containerId: string;
name: string;
cpuPercentage: string;
memoryUsage: string;
memoryPercentage: string;
networkIO: string;
blockIO: string;
pids: string;
}
export interface DockerImage {
repository: string;
tag: string;
imageId: string;
created: string;
size: string;
}
export interface DockerVolume {
driver: string;
name: string;
mountpoint: string;
created: string;
}
// Extended service methods
export class DockerExtendedService extends DockerService {
/**
* Get container statistics
*/
getContainerStats(containerName: string): Observable<DockerStats[]> {
return this.executeCommand(`docker stats ${containerName} --no-stream --format "table {{.Container}}\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\\t{{.MemPerc}}\\t{{.NetIO}}\\t{{.BlockIO}}\\t{{.PIDs}}"`)
.pipe(
map(result => {
if (result.success && Array.isArray(result.output)) {
return this.parseStatsOutput(result.output);
}
return [];
})
);
}
/**
* Get Docker volumes
*/
listVolumes(): Observable<DockerVolume[]> {
return this.executeCommand('docker volume ls --format "table {{.Driver}}\\t{{.Name}}"')
.pipe(
map(result => {
if (result.success && Array.isArray(result.output)) {
return this.parseVolumeOutput(result.output);
}
return [];
})
);
}
/**
* Clean up Docker system
*/
systemPrune(force: boolean = false): Observable<DockerResult> {
const command = force ? 'docker system prune -f' : 'docker system prune';
return this.executeCommand(command);
}
private parseStatsOutput(output: string[]): DockerStats[] {
const stats: DockerStats[] = [];
const dataLines = output.slice(1); // Skip header
dataLines.forEach(line => {
const parts = line.split('\t');
if (parts.length >= 8) {
stats.push({
containerId: parts[0],
name: parts[1],
cpuPercentage: parts[2],
memoryUsage: parts[3],
memoryPercentage: parts[4],
networkIO: parts[5],
blockIO: parts[6],
pids: parts[7]
});
}
});
return stats;
}
private parseVolumeOutput(output: string[]): DockerVolume[] {
const volumes: DockerVolume[] = [];
const dataLines = output.slice(1); // Skip header
dataLines.forEach(line => {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
volumes.push({
driver: parts[0],
name: parts[1],
mountpoint: '',
created: ''
});
}
});
return volumes;
}
}
@@ -40,6 +40,11 @@ export const USER_ROUTS: Routes = [
path: 'images',
loadComponent: () => import('../user-module/images.component/images.component').then((c) => c.ImagesComponent),
data:{title: 'Images', icon: 'imagesmode', nav:true}
},
{
path:'docker',
loadComponent: () => import('../user-module/docker-management-component/docker-management-component').then((c)=>c.DockerManagementComponent),
data:{title: 'Docker', icon: 'docker', nav:true}
}
]
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { FileBrowserTreeService } from './file-browser-tree.service';
describe('FileBrowserTreeService', () => {
let service: FileBrowserTreeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(FileBrowserTreeService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -0,0 +1,92 @@
import { Injectable } from '@angular/core';
import {GlobalConstants} from '../global-constants';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
export interface FileItem {
name: string;
type: 'file' | 'directory';
size: number;
lastModified: Date;
path: string;
}
@Injectable({
providedIn: 'root'
})
export class FileBrowserTreeService {
private baseUrl = `${GlobalConstants.API_URL}/file-browser-tree/files`;
constructor(private http: HttpClient) {}
getFiles(path: string): Observable<FileItem[]> {
const encodedPath = encodeURIComponent(path);
return this.http.get<FileItem[]>(`${this.baseUrl}?path=${encodedPath}`);
}
// New method to get only folders for tree view
getFolders(path: string): Observable<FileItem[]> {
const encodedPath = encodeURIComponent(path);
return this.http.get<FileItem[]>(`${this.baseUrl}?path=${encodedPath}`)
.pipe(
map(files => files.filter(file => file.type === 'directory'))
);
}
// Alternative: dedicated backend endpoint for folders only
getFoldersOnly(path: string): Observable<FileItem[]> {
const encodedPath = encodeURIComponent(path);
return this.http.get<FileItem[]>(`${this.baseUrl}/folders?path=${encodedPath}`);
}
downloadFile(filePath: string): Observable<Blob> {
const encodedPath = encodeURIComponent(filePath);
return this.http.get(`${this.baseUrl}/download?path=${encodedPath}`, {
responseType: 'blob'
});
}
uploadFile(path: string, file: File): Observable<any> {
const formData = new FormData();
formData.append('file', file);
formData.append('path', path);
return this.http.post(`${this.baseUrl}/upload`, formData);
}
createFolder(path: string, folderName: string): Observable<any> {
return this.http.post(`${this.baseUrl}/folder`, {
path: path,
name: folderName
});
}
deleteFile(filePath: string): Observable<any> {
const encodedPath = encodeURIComponent(filePath);
return this.http.delete(`${this.baseUrl}?path=${encodedPath}`);
}
// Search functionality
searchFiles(path: string, query: string): Observable<FileItem[]> {
const encodedPath = encodeURIComponent(path);
const encodedQuery = encodeURIComponent(query);
return this.http.get<FileItem[]>(`${this.baseUrl}/search?path=${encodedPath}&query=${encodedQuery}`);
}
// Move/rename functionality
moveFile(sourcePath: string, targetPath: string): Observable<any> {
return this.http.post(`${this.baseUrl}/move`, {
sourcePath: sourcePath,
targetPath: targetPath
});
}
// Copy functionality
copyFile(sourcePath: string, targetPath: string): Observable<any> {
return this.http.post(`${this.baseUrl}/copy`, {
sourcePath: sourcePath,
targetPath: targetPath
});
}
}
+4
View File
@@ -6,6 +6,10 @@
#RUN ./gradlew build -x test
FROM openjdk:24
ADD https://download.docker.com/linux/static/stable/x86_64/docker-24.0.5.tgz /tmp/
RUN tar xzvf /tmp/docker-24.0.5.tgz -C /usr/local/bin --strip-components=1 docker/docker
WORKDIR /jambotron/
#VOLUME /jambotron_data/uploads
COPY './build/libs/jambotron-0.0.1-SNAPSHOT.jar' '/app/jambotron.jar'
+9 -1
View File
@@ -13,8 +13,16 @@ services:
volumes:
- certs:/certs
- jambotron_data:/jambotron_data
# Mount host Docker socket
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/bin/docker
# env_file: "webapp.env"
stdin_open: true
tty: true
environment:
DOCKER_HOST: unix:///var/run/docker.sock
SSL_ENABLED: "true"
SERVER_PORT: 443
FULLCHAINPEM: /certs/live/jambotron.run.place/fullchain.pem
@@ -29,7 +37,7 @@ services:
SPRING_FLYWAY_USER: admin
SPRING_FLYWAY_PASSWORD: postgrespw
SPRING_FLYWAY_URL: jdbc:postgresql://postgres_jambotron:5432/jambotronDB
# Uncomment the following lines to use Koyeb secrets for database credentials
# SPRING_DATASOURCE_URL: jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app:5432/jambotronDB
# SPRING_DATASOURCE_USERNAME: koyeb-adm
# SPRING_DATASOURCE_PASSWORD: npg_HfFEUA7bay1i
@@ -0,0 +1,140 @@
package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.docker.ContainerRunRequest;
import com.jambotronGroup.jambotron.docker.DockerResult;
import com.jambotronGroup.jambotron.docker.DockerService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@RestController
@RequestMapping("/api/user/docker")
public class DockerController {
@Autowired
private DockerService dockerService;
/**
* Выполнить произвольную Docker команду
*/
@PostMapping("/execute")
public ResponseEntity<?> executeCommand(@RequestBody Map<String, String> request) {
String command = request.get("command");
if (command == null || command.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Команда не может быть пустой");
}
try {
DockerResult result = dockerService.executeCommand(command);
return ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"exitCode", result.getExitCode(),
"output", result.getOutput(),
"command", result.getCommand()
));
} catch (Exception e) {
return ResponseEntity.internalServerError()
.body(Map.of("error", e.getMessage()));
}
}
/**
* Запустить контейнер
*/
@PostMapping("/container/run")
public ResponseEntity<?> runContainer(@RequestBody ContainerRunRequest request) {
DockerResult result = dockerService.runContainer(
request.getImageName(),
request.getContainerName(),
request.getPorts(),
request.getEnvironmentVars()
);
return ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"output", result.getOutputAsString(),
"containerId", result.isSuccess() && !result.getOutput().isEmpty()
? result.getOutput().get(0) : null
));
}
/**
* Остановить контейнер
*/
@PostMapping("/container/{containerName}/stop")
public ResponseEntity<?> stopContainer(@PathVariable String containerName) {
DockerResult result = dockerService.stopContainer(containerName);
return ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"output", result.getOutputAsString()
));
}
/**
* Получить список контейнеров
*/
@GetMapping("/containers")
public ResponseEntity<?> listContainers() {
DockerResult result = dockerService.listContainers();
return ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"containers", result.getOutput()
));
}
/**
* Получить логи контейнера
*/
@GetMapping("/container/{containerName}/logs")
public ResponseEntity<?> getContainerLogs(
@PathVariable String containerName,
@RequestParam(defaultValue = "100") int lines) {
DockerResult result = dockerService.getContainerLogs(containerName, lines);
return ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"logs", result.getOutput()
));
}
/**
* Выполнить команду в контейнере
*/
@PostMapping("/container/{containerName}/exec")
public ResponseEntity<?> execInContainer(
@PathVariable String containerName,
@RequestBody Map<String, String> request) {
String command = request.get("command");
DockerResult result = dockerService.execInContainer(containerName, command);
return ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"output", result.getOutput()
));
}
/**
* Асинхронное выполнение команды
*/
@PostMapping("/execute/async")
public CompletableFuture<ResponseEntity<Map<String, Object>>> executeCommandAsync(
@RequestBody Map<String, String> request) {
String command = request.get("command");
return dockerService.executeCommandAsync(command)
.thenApply(result -> ResponseEntity.ok(Map.of(
"success", result.isSuccess(),
"output", result.getOutput(),
"exitCode", result.getExitCode()
)))
.exceptionally(ex -> ResponseEntity.internalServerError()
.body(Map.of("error", ex.getMessage())));
}
}
@@ -0,0 +1,154 @@
package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.fileBrowserTree.CreateFolderRequest;
import com.jambotronGroup.jambotron.fileBrowserTree.FileItemDto;
import com.jambotronGroup.jambotron.fileBrowserTree.FileBrowserTreeService;
import com.jambotronGroup.jambotron.fileUpload.ResponseMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/api/file-browser-tree/files")
public class FileBrowserTreeController {
@Autowired
private FileBrowserTreeService fileBrowserTreeService;
@GetMapping
public ResponseEntity<List<FileItemDto>> getFiles(@RequestParam String path) {
try {
List<FileItemDto> files = fileBrowserTreeService.listFiles(path);
return ResponseEntity.ok(files);
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
// New endpoint specifically for folders only (for tree view)
@GetMapping("/folders")
public ResponseEntity<List<FileItemDto>> getFolders(@RequestParam String path) {
try {
List<FileItemDto> folders = fileBrowserTreeService.listFolders(path);
return ResponseEntity.ok(folders);
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
// Search endpoint
@GetMapping("/search")
public ResponseEntity<List<FileItemDto>> searchFiles(
@RequestParam String path,
@RequestParam String query) {
try {
List<FileItemDto> files = fileBrowserTreeService.searchFiles(path, query);
return ResponseEntity.ok(files);
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(@RequestParam String path) {
try {
Resource resource = fileBrowserTreeService.loadFileAsResource(path);
String filename = resource.getFilename();
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/upload")
public ResponseEntity<ResponseMessage> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam("path") String path) {
try {
fileBrowserTreeService.storeFile(file, path);
return ResponseEntity.ok(new ResponseMessage("File uploaded successfully: " + file.getOriginalFilename()));
} catch (IOException e) {
return ResponseEntity.badRequest()
.body(new ResponseMessage("Failed to upload file: " + e.getMessage()));
}
}
@PostMapping("/folder")
public ResponseEntity<ResponseMessage> createFolder(@RequestBody CreateFolderRequest request) {
try {
fileBrowserTreeService.createFolder(request.getPath(), request.getName());
return ResponseEntity.ok(new ResponseMessage("Folder created successfully: " + request.getName()));
} catch (IOException e) {
return ResponseEntity.badRequest()
.body(new ResponseMessage("Failed to create folder: " + e.getMessage()));
}
}
@DeleteMapping
public ResponseEntity<ResponseMessage> deleteFile(@RequestParam String path) {
try {
fileBrowserTreeService.deleteFile(path);
return ResponseEntity.ok(new ResponseMessage("File deleted successfully: " + path));
} catch (IOException e) {
return ResponseEntity.badRequest()
.body(new ResponseMessage("Failed to delete file/folder: " + e.getMessage()));
}
}
// Move/rename endpoint
@PostMapping("/move")
public ResponseEntity<ResponseMessage> moveFile(@RequestBody MoveRequest request) {
try {
fileBrowserTreeService.moveFile(request.getSourcePath(), request.getTargetPath());
return ResponseEntity.ok(new ResponseMessage("File moved successfully from " + request.getSourcePath() + " to " + request.getTargetPath()));
} catch (IOException e) {
return ResponseEntity.badRequest()
.body(new ResponseMessage("Failed to move file: " + e.getMessage()));
}
}
// Copy endpoint
@PostMapping("/copy")
public ResponseEntity<ResponseMessage> copyFile(@RequestBody CopyRequest request) {
try {
fileBrowserTreeService.copyFile(request.getSourcePath(), request.getTargetPath());
return ResponseEntity.ok(new ResponseMessage("File copied successfully from " + request.getSourcePath() + " to " + request.getTargetPath()));
} catch (IOException e) {
return ResponseEntity.badRequest()
.body(new ResponseMessage("Failed to copy file: " + e.getMessage()));
}
}
// Inner classes for request DTOs
public static class MoveRequest {
private String sourcePath;
private String targetPath;
public String getSourcePath() { return sourcePath; }
public void setSourcePath(String sourcePath) { this.sourcePath = sourcePath; }
public String getTargetPath() { return targetPath; }
public void setTargetPath(String targetPath) { this.targetPath = targetPath; }
}
public static class CopyRequest {
private String sourcePath;
private String targetPath;
public String getSourcePath() { return sourcePath; }
public void setSourcePath(String sourcePath) { this.sourcePath = sourcePath; }
public String getTargetPath() { return targetPath; }
public void setTargetPath(String targetPath) { this.targetPath = targetPath; }
}
}
@@ -0,0 +1,21 @@
package com.jambotronGroup.jambotron.docker;
public class ContainerRunRequest {
private String imageName;
private String containerName;
private String ports;
private String environmentVars;
// Getters and setters
public String getImageName() { return imageName; }
public void setImageName(String imageName) { this.imageName = imageName; }
public String getContainerName() { return containerName; }
public void setContainerName(String containerName) { this.containerName = containerName; }
public String getPorts() { return ports; }
public void setPorts(String ports) { this.ports = ports; }
public String getEnvironmentVars() { return environmentVars; }
public void setEnvironmentVars(String environmentVars) { this.environmentVars = environmentVars; }
}
@@ -0,0 +1,29 @@
package com.jambotronGroup.jambotron.docker;
import java.util.List;
public class DockerResult {
private final boolean success;
private final int exitCode;
private final List<String> output;
private final String command;
public DockerResult(boolean success, int exitCode, List<String> output, String command) {
this.success = success;
this.exitCode = exitCode;
this.output = output;
this.command = command;
}
public boolean isSuccess() { return success; }
public int getExitCode() { return exitCode; }
public List<String> getOutput() { return output; }
public String getCommand() { return command; }
public String getOutputAsString() { return String.join("\n", output); }
@Override
public String toString() {
return String.format("DockerResult{success=%s, exitCode=%d, command='%s', output='%s'}",
success, exitCode, command, getOutputAsString());
}
}
@@ -0,0 +1,159 @@
package com.jambotronGroup.jambotron.docker;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Service
public class DockerService {
private static final Logger logger = LoggerFactory.getLogger(DockerService.class);
/**
* Выполнить Docker команду синхронно
*/
public DockerResult executeCommand(String command) {
try {
String os = System.getProperty("os.name").toLowerCase();
ProcessBuilder processBuilder = new ProcessBuilder();
if (os.contains("win")) {
processBuilder.command("cmd.exe", "/c", command);
} else {
processBuilder.command("bash", "-c", command);
}
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
List<String> output = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.add(line);
logger.info("Docker output: {}", line);
}
}
int exitCode = process.waitFor();
return new DockerResult(exitCode == 0, exitCode, output, command);
} catch (IOException | InterruptedException e) {
logger.error("Ошибка выполнения Docker команды: {}", command, e);
return new DockerResult(false, -1, List.of("Error: " + e.getMessage()), command);
}
}
/**
* Выполнить Docker команду асинхронно
*/
public CompletableFuture<DockerResult> executeCommandAsync(String command) {
return CompletableFuture.supplyAsync(() -> executeCommand(command));
}
/**
* Запустить Docker контейнер
*/
public DockerResult runContainer(String imageName, String containerName,
String ports, String environmentVars) {
StringBuilder cmd = new StringBuilder("docker run -d");
if (containerName != null && !containerName.isEmpty()) {
cmd.append(" --name ").append(containerName);
}
if (ports != null && !ports.isEmpty()) {
cmd.append(" -p ").append(ports);
}
if (environmentVars != null && !environmentVars.isEmpty()) {
cmd.append(" ").append(environmentVars);
}
cmd.append(" ").append(imageName);
logger.info("Запуск контейнера: {}", cmd.toString());
return executeCommand(cmd.toString());
}
/**
* Остановить Docker контейнер
*/
public DockerResult stopContainer(String containerName) {
String command = "docker stop " + containerName;
logger.info("Остановка контейнера: {}", command);
return executeCommand(command);
}
/**
* Удалить Docker контейнер
*/
public DockerResult removeContainer(String containerName) {
String command = "docker rm " + containerName;
logger.info("Удаление контейнера: {}", command);
return executeCommand(command);
}
/**
* Получить список запущенных контейнеров
*/
public DockerResult listContainers() {
String command = "docker ps";
return executeCommand(command);
}
/**
* Получить логи контейнера
*/
public DockerResult getContainerLogs(String containerName, int lines) {
String command = String.format("docker logs --tail %d %s", lines, containerName);
return executeCommand(command);
}
/**
* Построить Docker образ
*/
public DockerResult buildImage(String dockerfilePath, String imageName, String tag) {
String fullTag = tag != null ? imageName + ":" + tag : imageName;
String command = String.format("docker build -t %s %s", fullTag, dockerfilePath);
logger.info("Сборка образа: {}", command);
return executeCommand(command);
}
/**
* Выполнить команду внутри контейнера
*/
public DockerResult execInContainer(String containerName, String command) {
String dockerCommand = String.format("docker exec %s %s", containerName, command);
logger.info("Выполнение команды в контейнере: {}", dockerCommand);
return executeCommand(dockerCommand);
}
/**
* Запустить Docker Compose
*/
public DockerResult dockerComposeUp(String composePath) {
String command = String.format("docker-compose -f %s up -d", composePath);
logger.info("Запуск Docker Compose: {}", command);
return executeCommand(command);
}
/**
* Остановить Docker Compose
*/
public DockerResult dockerComposeDown(String composePath) {
String command = String.format("docker-compose -f %s down", composePath);
logger.info("Остановка Docker Compose: {}", command);
return executeCommand(command);
}
}
@@ -0,0 +1,31 @@
package com.jambotronGroup.jambotron.fileBrowserTree;
public class CreateFolderRequest {
private String path;
private String name;
// Constructors
public CreateFolderRequest() {}
public CreateFolderRequest(String path, String name) {
this.path = path;
this.name = name;
}
// Getters and Setters
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@@ -0,0 +1,410 @@
package com.jambotronGroup.jambotron.fileBrowserTree;
import com.jambotronGroup.jambotron.model.ERole;
import com.jambotronGroup.jambotron.security.AuthenticationFacade;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Stream;
@Service
public class FileBrowserTreeService {
@Autowired
private AuthenticationFacade authenticationFacade;
private final Path root = Paths.get("/jambotron_data");
private Path getRootPath() {
boolean isAdmin = authenticationFacade.getUser()
.getRoles().stream()
.anyMatch(role -> role.getName().equals(ERole.ROLE_ADMIN));
if(isAdmin){
// Admins can access the root directory
return root.normalize().toAbsolutePath();
}
return root.resolve(authenticationFacade.getUser().getUsername()).normalize().toAbsolutePath();
}
private Path resolvePath(String path) throws IOException {
Path root = getRootPath();
Path resolvedPath = root.resolve(path.startsWith("/") ? path.substring(1) : path)
.normalize().toAbsolutePath();
// Security check: ensure the resolved path is within the root directory
if (!resolvedPath.startsWith(root)) {
throw new SecurityException("Access denied: Path is outside root directory");
}
return resolvedPath;
}
public List<FileItemDto> listFiles(String path) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath)) {
throw new IOException("Directory does not exist: " + path);
}
if (!Files.isDirectory(targetPath)) {
throw new IOException("Path is not a directory: " + path);
}
List<FileItemDto> files = new ArrayList<>();
try (Stream<Path> stream = Files.list(targetPath)) {
stream.forEach(filePath -> {
try {
FileItemDto dto = createFileItemDto(filePath);
files.add(dto);
} catch (IOException e) {
// Log and skip files that can't be read
System.err.println("Error reading file: " + filePath + " - " + e.getMessage());
}
});
}
return sortFiles(files);
}
// New method to list only folders
public List<FileItemDto> listFolders(String path) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath)) {
throw new IOException("Directory does not exist: " + path);
}
if (!Files.isDirectory(targetPath)) {
throw new IOException("Path is not a directory: " + path);
}
List<FileItemDto> folders = new ArrayList<>();
try (Stream<Path> stream = Files.list(targetPath)) {
stream.filter(Files::isDirectory)
.forEach(folderPath -> {
try {
FileItemDto dto = createFileItemDto(folderPath);
folders.add(dto);
} catch (IOException e) {
System.err.println("Error reading folder: " + folderPath + " - " + e.getMessage());
}
});
}
// Sort folders alphabetically
folders.sort((a, b) -> a.getName().compareToIgnoreCase(b.getName()));
return folders;
}
// Search functionality
public List<FileItemDto> searchFiles(String path, String query) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath)) {
throw new IOException("Directory does not exist: " + path);
}
List<FileItemDto> matchingFiles = new ArrayList<>();
String lowerQuery = query.toLowerCase();
try (Stream<Path> stream = Files.walk(targetPath, 10)) { // Max depth of 10
stream.filter(filePath -> !filePath.equals(targetPath))
.filter(filePath -> filePath.getFileName().toString().toLowerCase().contains(lowerQuery))
.forEach(filePath -> {
try {
FileItemDto dto = createFileItemDto(filePath);
matchingFiles.add(dto);
} catch (IOException e) {
System.err.println("Error reading file during search: " + filePath + " - " + e.getMessage());
}
});
}
return sortFiles(matchingFiles);
}
// Helper method to create FileItemDto
private FileItemDto createFileItemDto(Path filePath) throws IOException {
FileItemDto dto = new FileItemDto();
dto.setName(filePath.getFileName().toString());
dto.setType(Files.isDirectory(filePath) ? "directory" : "file");
// Calculate relative path from root
Path relativePath = getRootPath().relativize(filePath);
String pathString = "/" + relativePath.toString().replace("\\", "/");
dto.setPath(pathString);
if (Files.isRegularFile(filePath)) {
dto.setSize(Files.size(filePath));
} else {
dto.setSize(0L);
}
dto.setLastModified(new Date(Files.getLastModifiedTime(filePath).toMillis()));
return dto;
}
// Helper method to sort files (directories first, then alphabetically)
private List<FileItemDto> sortFiles(List<FileItemDto> files) {
files.sort((a, b) -> {
if (a.getType().equals(b.getType())) {
return a.getName().compareToIgnoreCase(b.getName());
}
return "directory".equals(a.getType()) ? -1 : 1;
});
return files;
}
public Resource loadFileAsResource(String path) throws IOException {
Path filePath = resolvePath(path);
if (!Files.exists(filePath) || !Files.isReadable(filePath)) {
throw new IOException("File not found or not readable: " + path);
}
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw new IOException("File not found or not readable: " + path);
}
}
public void storeFile(MultipartFile file, String targetPath) throws IOException {
String fileName = StringUtils.cleanPath(file.getOriginalFilename());
if (fileName.contains("..")) {
throw new IOException("Invalid file path: " + fileName);
}
Path targetDir = resolvePath(targetPath);
// Create directories if they don't exist
if (!Files.exists(targetDir)) {
Files.createDirectories(targetDir);
}
Path targetFilePath = targetDir.resolve(fileName);
Files.copy(file.getInputStream(), targetFilePath, StandardCopyOption.REPLACE_EXISTING);
}
public void createFolder(String path, String folderName) throws IOException {
if (folderName.contains("..") || folderName.contains("/") || folderName.contains("\\")) {
throw new IOException("Invalid folder name: " + folderName);
}
Path targetPath = resolvePath(path);
Path newFolderPath = targetPath.resolve(folderName);
if (Files.exists(newFolderPath)) {
throw new IOException("Folder already exists: " + folderName);
}
Files.createDirectories(newFolderPath);
}
public void deleteFile(String path) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath)) {
throw new IOException("File or directory does not exist: " + path);
}
if (Files.isDirectory(targetPath)) {
// Delete directory and all its contents
deleteDirectoryRecursively(targetPath);
} else {
Files.delete(targetPath);
}
}
// Move/rename functionality
public void moveFile(String sourcePath, String targetPath) throws IOException {
Path source = resolvePath(sourcePath);
Path target = resolvePath(targetPath);
if (!Files.exists(source)) {
throw new IOException("Source file does not exist: " + sourcePath);
}
// Create target directory if it doesn't exist
Path targetParent = target.getParent();
if (targetParent != null && !Files.exists(targetParent)) {
Files.createDirectories(targetParent);
}
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
// Copy functionality
public void copyFile(String sourcePath, String targetPath) throws IOException {
Path source = resolvePath(sourcePath);
Path target = resolvePath(targetPath);
if (!Files.exists(source)) {
throw new IOException("Source file does not exist: " + sourcePath);
}
// Create target directory if it doesn't exist
Path targetParent = target.getParent();
if (targetParent != null && !Files.exists(targetParent)) {
Files.createDirectories(targetParent);
}
if (Files.isDirectory(source)) {
copyDirectoryRecursively(source, target);
} else {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
// Get directory size (recursive)
public long getDirectorySize(String path) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath) || !Files.isDirectory(targetPath)) {
return 0L;
}
try (Stream<Path> stream = Files.walk(targetPath)) {
return stream
.filter(Files::isRegularFile)
.mapToLong(filePath -> {
try {
return Files.size(filePath);
} catch (IOException e) {
return 0L;
}
})
.sum();
}
}
// Get file count in directory
public long getFileCount(String path) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath) || !Files.isDirectory(targetPath)) {
return 0L;
}
try (Stream<Path> stream = Files.list(targetPath)) {
return stream.count();
}
}
// Check if path exists
public boolean pathExists(String path) throws IOException {
try {
Path targetPath = resolvePath(path);
return Files.exists(targetPath);
} catch (SecurityException e) {
return false;
}
}
// Get file/directory info
public FileItemDto getFileInfo(String path) throws IOException {
Path targetPath = resolvePath(path);
if (!Files.exists(targetPath)) {
throw new IOException("File or directory does not exist: " + path);
}
return createFileItemDto(targetPath);
}
private void deleteDirectoryRecursively(Path directory) throws IOException {
try (Stream<Path> stream = Files.walk(directory)) {
stream.sorted((a, b) -> b.getNameCount() - a.getNameCount())
.forEach(path -> {
try {
Files.delete(path);
} catch (IOException e) {
System.err.println("Failed to delete: " + path + " - " + e.getMessage());
}
});
}
}
private void copyDirectoryRecursively(Path source, Path target) throws IOException {
Files.createDirectories(target);
try (Stream<Path> stream = Files.walk(source)) {
stream.forEach(sourcePath -> {
try {
Path targetPath = target.resolve(source.relativize(sourcePath));
if (Files.isDirectory(sourcePath)) {
Files.createDirectories(targetPath);
} else {
Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
System.err.println("Failed to copy: " + sourcePath + " - " + e.getMessage());
}
});
}
}
// Bulk operations
public void deleteMultipleFiles(List<String> paths) throws IOException {
List<String> failedDeletions = new ArrayList<>();
for (String path : paths) {
try {
deleteFile(path);
} catch (IOException e) {
failedDeletions.add(path);
System.err.println("Failed to delete: " + path + " - " + e.getMessage());
}
}
if (!failedDeletions.isEmpty()) {
throw new IOException("Failed to delete " + failedDeletions.size() + " files");
}
}
// Get disk usage information
public DiskUsageInfo getDiskUsage() throws IOException {
Path rootPath = getRootPath();
FileStore store = Files.getFileStore(rootPath);
long totalSpace = store.getTotalSpace();
long usableSpace = store.getUsableSpace();
long usedSpace = totalSpace - usableSpace;
return new DiskUsageInfo(totalSpace, usedSpace, usableSpace);
}
// Inner class for disk usage info
public static class DiskUsageInfo {
private final long totalSpace;
private final long usedSpace;
private final long availableSpace;
public DiskUsageInfo(long totalSpace, long usedSpace, long availableSpace) {
this.totalSpace = totalSpace;
this.usedSpace = usedSpace;
this.availableSpace = availableSpace;
}
public long getTotalSpace() { return totalSpace; }
public long getUsedSpace() { return usedSpace; }
public long getAvailableSpace() { return availableSpace; }
public double getUsagePercentage() {
return totalSpace > 0 ? (double) usedSpace / totalSpace * 100 : 0;
}
}
}
@@ -0,0 +1,67 @@
package com.jambotronGroup.jambotron.fileBrowserTree;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.util.Date;
public class FileItemDto {
private String name;
private String type; // "file" or "directory"
private long size;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSZ")
private Date lastModified;
private String path;
// Constructors
public FileItemDto() {}
public FileItemDto(String name, String type, long size, Date lastModified, String path) {
this.name = name;
this.type = type;
this.size = size;
this.lastModified = lastModified;
this.path = path;
}
// Getters and Setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public Date getLastModified() {
return lastModified;
}
public void setLastModified(Date lastModified) {
this.lastModified = lastModified;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
}
@@ -45,7 +45,7 @@ public class InitDataService {
private UserRepository _userRepository;
private final Path _initDataPath = Path.of("/jambotron-data/initData/");
private final Path _initDataPath = Path.of("/jambotron_data/initData/");
public InitDataService(){
@@ -122,9 +122,12 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
.requestMatchers("/api/moderator/**").hasRole("MODERATOR")
//.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").permitAll()
.requestMatchers("/api/admin/json-dump/**").permitAll()
.requestMatchers("/api/admin/json-dump/import/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/admin/json-dump/**").hasRole("ADMIN")
.requestMatchers("/api/admin/json-dump/import/**").hasRole("ADMIN")
.requestMatchers("/api/file-browser/files/**").hasAnyRole("USER", "MODERATOR", "ADMIN")
.requestMatchers("/api/file-browser-tree/files/**").hasAnyRole("USER", "MODERATOR", "ADMIN")
.anyRequest().authenticated()
);
@@ -3,6 +3,7 @@ logging:
root: INFO
com.jambotronGroup.jambotron: DEBUG
org.springframework: WARN
com.example.service.DockerService: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} %-5level - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
@@ -11,3 +12,7 @@ logging:
max-size: 10MB
max-history: 30
docker:
socket-path: /var/run/docker.sock
enable-security: true
command-timeout: 30000
@@ -3,6 +3,7 @@ logging:
root: INFO
com.jambotronGroup.jambotron: DEBUG
org.springframework: WARN
com.example.service.DockerService: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} %-5level - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
@@ -17,3 +18,8 @@ server:
certificate: ${FULLCHAINPEM:""} # Default to empty string if FULLCHAINPEM is not set
certificate-private-key: ${PRIVKEYPEM:""} # Default to empty string if PRIVKEYPEM is not set
# port: ${SERVER_PORT:443} # Default to 443 if SERVER_PORT is not set
docker:
socket-path: /var/run/docker.sock
enable-security: true
command-timeout: 30000