diff --git a/backend/app/api/routes/routers.py b/backend/app/api/routes/routers.py index 22d1938..5a30bbc 100644 --- a/backend/app/api/routes/routers.py +++ b/backend/app/api/routes/routers.py @@ -28,8 +28,6 @@ def serialize_router(router: Router, global_settings) -> RouterResponse: effective_username = router_user uses_global_switchos_credentials = False has_effective_password = bool(router_password) - uses_global_ssh_key = False - has_effective_ssh_key = False if router.device_type == 'switchos': effective_username = router_user or default_swos_user @@ -37,15 +35,11 @@ def serialize_router(router: Router, global_settings) -> RouterResponse: (not router_user and default_swos_user) or (not router_password and default_swos_password) ) has_effective_password = bool(router_password or default_swos_password) - else: - uses_password_auth = bool(router_password) - uses_global_ssh_key = bool(has_global_key and not has_router_key and not uses_password_auth) - has_effective_ssh_key = bool(has_router_key or uses_global_ssh_key) payload = RouterResponse.model_validate(router, from_attributes=True).model_dump() payload['effective_username'] = effective_username - payload['uses_global_ssh_key'] = uses_global_ssh_key - payload['has_effective_ssh_key'] = has_effective_ssh_key + payload['uses_global_ssh_key'] = router.device_type == 'routeros' and has_global_key and not has_router_key + payload['has_effective_ssh_key'] = router.device_type == 'routeros' and (has_router_key or has_global_key) payload['uses_global_switchos_credentials'] = uses_global_switchos_credentials payload['has_effective_password'] = has_effective_password payload['supports_export'] = router.device_type == 'routeros' diff --git a/backend/app/services/router_service.py b/backend/app/services/router_service.py index 925a957..5bd17ee 100644 --- a/backend/app/services/router_service.py +++ b/backend/app/services/router_service.py @@ -15,12 +15,6 @@ from app.services.swos_beta_service import swos_beta_service class RouterService: - connect_timeout_seconds = 10 - auth_timeout_seconds = 10 - banner_timeout_seconds = 10 - command_timeout_seconds = 20 - sftp_timeout_seconds = 20 - def ping(self, router: Router): if getattr(router, 'disable_ping', False): return {'router_id': router.id, 'reachable': False, 'latency_ms': None, 'disabled': True} @@ -65,41 +59,28 @@ class RouterService: def _connect(self, router: Router, global_ssh_key: str | None = None): client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - router_key = (router.ssh_key or '').strip() - router_password = (router.ssh_password or '').strip() - global_key = (global_ssh_key or '').strip() - - use_password_auth = bool(router_password and not router_key) - key_source = router_key or ('' if use_password_auth else global_key) - - connect_kwargs = { - 'hostname': router.host, - 'port': router.port, - 'username': router.ssh_user, - 'timeout': self.connect_timeout_seconds, - 'auth_timeout': self.auth_timeout_seconds, - 'banner_timeout': self.banner_timeout_seconds, - 'allow_agent': False, - 'look_for_keys': False, - } - + key_source = router.ssh_key.strip() if router.ssh_key and router.ssh_key.strip() else (global_ssh_key or "") if key_source: pkey = self._load_pkey(key_source) - client.connect(pkey=pkey, **connect_kwargs) + client.connect(router.host, port=router.port, username=router.ssh_user, pkey=pkey, timeout=10) else: - client.connect(password=router_password or None, **connect_kwargs) - - transport = client.get_transport() - if transport is not None: - transport.set_keepalive(15) + client.connect( + router.host, + port=router.port, + username=router.ssh_user, + password=router.ssh_password, + timeout=10, + allow_agent=False, + look_for_keys=False, + banner_timeout=10, + ) return client def export(self, router: Router, global_ssh_key: str | None = None) -> str: if router.device_type != 'routeros': raise ValueError('Export tekstowy jest dostępny tylko dla RouterOS.') client = self._connect(router, global_ssh_key) - _, stdout, _ = client.exec_command('/export', timeout=self.command_timeout_seconds) + _, stdout, _ = client.exec_command('/export') output = stdout.read().decode('utf-8', errors='ignore') client.close() return output @@ -111,10 +92,9 @@ class RouterService: return local_path client = self._connect(router, global_ssh_key) - _, stdout, _ = client.exec_command(f'/system backup save name={backup_name}', timeout=self.command_timeout_seconds) + _, stdout, _ = client.exec_command(f'/system backup save name={backup_name}') stdout.channel.recv_exit_status() sftp = client.open_sftp() - sftp.get_channel().settimeout(self.sftp_timeout_seconds) remote_file = f'{backup_name}.backup' sftp.get(remote_file, local_path) try: @@ -130,7 +110,6 @@ class RouterService: raise ValueError('Przywracanie plików jest dostępne tylko dla RouterOS.') client = self._connect(router, global_ssh_key) sftp = client.open_sftp() - sftp.get_channel().settimeout(self.sftp_timeout_seconds) target_name = Path(local_backup_path).name sftp.put(local_backup_path, target_name) sftp.close() @@ -140,9 +119,9 @@ class RouterService: tested_at = datetime.utcnow() try: client = self._connect(router, global_ssh_key) - _, stdout, _ = client.exec_command('/system resource print without-paging', timeout=self.command_timeout_seconds) + _, stdout, _ = client.exec_command('/system resource print without-paging') resource_output = stdout.read().decode('utf-8', errors='ignore') - _, stdout, _ = client.exec_command('/system identity print', timeout=self.command_timeout_seconds) + _, stdout, _ = client.exec_command('/system identity print') identity_output = stdout.read().decode('utf-8', errors='ignore') client.close() model = 'Unknown' diff --git a/backend/tests/test_routers.py b/backend/tests/test_routers.py index 6210253..567c8e6 100644 --- a/backend/tests/test_routers.py +++ b/backend/tests/test_routers.py @@ -60,60 +60,3 @@ def test_router_list_marks_global_ssh_key_usage(monkeypatch, tmp_path): payload = list_response.json() assert payload[0]["uses_global_ssh_key"] is True assert payload[0]["has_effective_ssh_key"] is True - - -def test_router_password_auth_overrides_global_ssh_key(monkeypatch, tmp_path): - monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'routers-password.db'}") - monkeypatch.setenv("DATA_DIR", str(tmp_path / 'data-password')) - monkeypatch.setenv("SECRET_KEY", "test-secret") - monkeypatch.setenv("DEFAULT_ADMIN_USERNAME", "admin") - monkeypatch.setenv("DEFAULT_ADMIN_PASSWORD", "admin") - - with TestClient(app) as client: - login_response = client.post("/api/auth/login", data={"username": "admin", "password": "admin"}) - token = login_response.json()["access_token"] - headers = {"Authorization": f"Bearer {token}"} - - settings_response = client.put( - "/api/settings", - json={ - "backup_retention_days": 7, - "log_retention_days": 7, - "export_cron": "", - "binary_cron": "", - "retention_cron": "", - "enable_auto_export": False, - "connection_test_interval_minutes": 0, - "global_ssh_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----", - "pushover_token": None, - "pushover_userkey": None, - "notify_failures_only": True, - "smtp_host": None, - "smtp_port": 587, - "smtp_login": None, - "smtp_password": None, - "smtp_notifications_enabled": False, - "recipient_email": None, - "clear_global_ssh_key": False - }, - headers=headers, - ) - assert settings_response.status_code == 200 - - create_response = client.post( - "/api/routers", - json={ - "name": "edge02", - "host": "10.0.0.2", - "port": 22, - "ssh_user": "admin", - "ssh_password": "secret-pass", - "ssh_key": None - }, - headers=headers, - ) - assert create_response.status_code == 200 - payload = create_response.json() - assert payload["uses_global_ssh_key"] is False - assert payload["has_effective_ssh_key"] is False - assert payload["has_effective_password"] is True diff --git a/frontend/src/app/core/services/ui.service.ts b/frontend/src/app/core/services/ui.service.ts index 72c72d2..6582692 100644 --- a/frontend/src/app/core/services/ui.service.ts +++ b/frontend/src/app/core/services/ui.service.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { ConfirmationService, MessageService } from 'primeng/api'; @@ -46,17 +45,6 @@ export class UiService { this.messageService.clear(); } - - apiError(error: unknown, fallbackDetailKey: string) { - const detail = this.extractErrorMessage(error) || this.t(fallbackDetailKey); - this.messageService.add({ - severity: 'error', - summary: this.t('toast.error'), - detail - }); - } - - confirm(options: ConfirmOptions): Promise { return new Promise((resolve) => { let resolved = false; @@ -87,42 +75,7 @@ export class UiService { return this.t(key, params); } - - private extractErrorMessage(error: unknown): string | null { - if (!error) { - return null; - } - if (error instanceof HttpErrorResponse) { - if ((error as { name?: string }).name === 'TimeoutError') { - return this.t('toast.requestTimeout'); - } - const payload = error.error; - if (typeof payload === 'string' && payload.trim()) { - return payload.trim(); - } - if (payload && typeof payload === 'object') { - const detail = (payload as { detail?: unknown }).detail; - if (typeof detail === 'string' && detail.trim()) { - return detail.trim(); - } - } - if (typeof error.message === 'string' && error.message.trim()) { - return error.message.trim(); - } - return null; - } - const maybeTimeout = error as { name?: string; message?: string }; - if (maybeTimeout.name === 'TimeoutError') { - return this.t('toast.requestTimeout'); - } - if (typeof maybeTimeout.message === 'string' && maybeTimeout.message.trim()) { - return maybeTimeout.message.trim(); - } - return null; - } - private t(key: string, params?: Record): string { return this.translate.instant(key, params); } } - diff --git a/frontend/src/app/features/auth/change-password-page.component.html b/frontend/src/app/features/auth/change-password-page.component.html index cd32840..cdba94a 100644 --- a/frontend/src/app/features/auth/change-password-page.component.html +++ b/frontend/src/app/features/auth/change-password-page.component.html @@ -42,7 +42,6 @@ {{ passwordsMatch ? ('auth.passwordsMatchHint' | translate) : ('auth.passwordsMismatch' | translate) }} {{ error }} diff --git a/frontend/src/app/features/diff-configs/diff-configs-page.component.html b/frontend/src/app/features/diff-configs/diff-configs-page.component.html index 0ebd29f..bc4b162 100644 --- a/frontend/src/app/features/diff-configs/diff-configs-page.component.html +++ b/frontend/src/app/features/diff-configs/diff-configs-page.component.html @@ -62,7 +62,7 @@ - + {{ 'files.fileColumn' | translate }} @@ -75,7 +75,7 @@ {{ 'files.fileColumn' | translate }} -
{{ item.file_name }}
+
{{ item.file_name }}
{{ 'files.checksum' | translate }}: {{ checksumShort(item.checksum) }} @@ -90,11 +90,19 @@ {{ 'files.compareColumn' | translate }} -
+
+
+ {{ 'files.compareColumn' | translate }} +
+ + + +
+
diff --git a/frontend/src/app/features/files/files-page.component.html b/frontend/src/app/features/files/files-page.component.html index 7c1ebac..923e2b8 100644 --- a/frontend/src/app/features/files/files-page.component.html +++ b/frontend/src/app/features/files/files-page.component.html @@ -87,7 +87,7 @@ - + @@ -108,7 +108,7 @@ {{ 'files.fileColumn' | translate }} -
{{ item.file_name }}
+
{{ item.file_name }}
{{ 'files.checksum' | translate }}: {{ checksumShort(item.checksum) }} @@ -132,24 +132,42 @@ {{ 'files.compareColumn' | translate }} -
+
+
+ {{ 'files.compareColumn' | translate }} +
+ + + +
+
{{ 'files.binaryNoCompare' | translate }} {{ 'files.actionsColumn' | translate }} -
+
+
+ {{ 'common.actions' | translate }} +
+ + + + + +
+
diff --git a/frontend/src/app/features/routers/router-detail-page.component.html b/frontend/src/app/features/routers/router-detail-page.component.html index 664c8b0..eebd999 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.html +++ b/frontend/src/app/features/routers/router-detail-page.component.html @@ -150,7 +150,7 @@
- + {{ 'files.fileColumn' | translate }}{{ 'files.createdColumn' | translate }}{{ 'common.actions' | translate }} @@ -158,7 +158,7 @@ {{ 'files.fileColumn' | translate }} -
{{ item.file_name }}
+
{{ item.file_name }}
{{ 'files.exportType' | translate }} @@ -167,13 +167,23 @@ {{ 'common.actions' | translate }} -
+
+
+ {{ 'common.actions' | translate }} +
+ + + + + +
+
@@ -183,7 +193,7 @@
- + {{ 'files.fileColumn' | translate }}{{ 'files.createdColumn' | translate }}{{ 'common.actions' | translate }} @@ -191,7 +201,7 @@ {{ 'files.fileColumn' | translate }} -
{{ item.file_name }}
+
{{ item.file_name }}
{{ 'files.binaryType' | translate }} @@ -200,12 +210,21 @@ {{ 'common.actions' | translate }} -
+
+
+ {{ 'common.actions' | translate }} +
+ + + + +
+
diff --git a/frontend/src/app/features/routers/router-detail-page.component.ts b/frontend/src/app/features/routers/router-detail-page.component.ts index 277486b..e7ba275 100644 --- a/frontend/src/app/features/routers/router-detail-page.component.ts +++ b/frontend/src/app/features/routers/router-detail-page.component.ts @@ -1,5 +1,4 @@ import { CommonModule } from '@angular/common'; -import { finalize, timeout } from 'rxjs'; import { HttpResponse } from '@angular/common/http'; import { Component, OnInit, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -243,25 +242,17 @@ export class RouterDetailPageComponent implements OnInit { if (payload.device_type === 'switchos') { payload.ssh_key = ''; } - this.api.http - .put(`${this.api.baseUrl}/routers/${this.routerId}`, payload) - .pipe( - timeout(15000), - finalize(() => { - this.saving = false; - }) - ) - .subscribe({ - next: (routerItem) => { - this.routerItem = routerItem; - this.connection = this.mapStoredConnection(routerItem); - this.editVisible = false; - this.ui.success('toast.routerUpdated'); - }, - error: (error) => { - this.ui.apiError(error, 'toast.routerSaveFailed'); - } - }); + this.api.http.put(`${this.api.baseUrl}/routers/${this.routerId}`, payload).subscribe({ + next: (routerItem) => { + this.routerItem = routerItem; + this.connection = this.mapStoredConnection(routerItem); + this.editVisible = false; + this.ui.success('toast.routerUpdated'); + }, + complete: () => { + this.saving = false; + } + }); } saveSettings() { @@ -277,25 +268,17 @@ export class RouterDetailPageComponent implements OnInit { payload.disable_export_backups = true; payload.disable_binary_backups = true; } - this.api.http - .put(`${this.api.baseUrl}/routers/${this.routerId}`, payload) - .pipe( - timeout(15000), - finalize(() => { - this.savingSettings = false; - }) - ) - .subscribe({ - next: (routerItem) => { - this.routerItem = routerItem; - this.connection = this.mapStoredConnection(routerItem); - this.patchSettingsForm(routerItem); - this.ui.success('toast.routerUpdated'); - }, - error: (error) => { - this.ui.apiError(error, 'toast.routerSaveFailed'); - } - }); + this.api.http.put(`${this.api.baseUrl}/routers/${this.routerId}`, payload).subscribe({ + next: (routerItem) => { + this.routerItem = routerItem; + this.connection = this.mapStoredConnection(routerItem); + this.patchSettingsForm(routerItem); + this.ui.success('toast.routerUpdated'); + }, + complete: () => { + this.savingSettings = false; + } + }); } private patchSettingsForm(item: DeviceItem) { @@ -344,28 +327,20 @@ export class RouterDetailPageComponent implements OnInit { return; } this.testing = true; - this.api.http - .get(`${this.api.baseUrl}/routers/${this.routerId}/test-connection`) - .pipe( - timeout(15000), - finalize(() => { - this.testing = false; - }) - ) - .subscribe({ - next: (result) => { - this.connection = result; - this.syncStoredConnection(result); - if (result.success) { - this.ui.success('toast.connectionSuccessful'); - } else { - this.ui.apiError({ message: result.error }, 'toast.connectionFailed'); - } - }, - error: (error) => { - this.ui.apiError(error, 'toast.connectionFailed'); + this.api.http.get(`${this.api.baseUrl}/routers/${this.routerId}/test-connection`).subscribe({ + next: (result) => { + this.connection = result; + this.syncStoredConnection(result); + if (result.success) { + this.ui.success('toast.connectionSuccessful'); + } else { + this.ui.error('toast.connectionFailed'); } - }); + }, + complete: () => { + this.testing = false; + } + }); } compareToLatest(id: number) { diff --git a/frontend/src/app/features/routers/routers-page.component.ts b/frontend/src/app/features/routers/routers-page.component.ts index fce71b5..d929c13 100644 --- a/frontend/src/app/features/routers/routers-page.component.ts +++ b/frontend/src/app/features/routers/routers-page.component.ts @@ -1,5 +1,4 @@ import { CommonModule } from '@angular/common'; -import { finalize, timeout } from 'rxjs'; import { Component, OnInit, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; @@ -168,23 +167,16 @@ export class RoutersPageComponent implements OnInit { ? this.api.http.put(`${this.api.baseUrl}/routers/${this.editingId}`, payload) : this.api.http.post(`${this.api.baseUrl}/routers`, payload); - request$ - .pipe( - timeout(15000), - finalize(() => { - this.saving = false; - }) - ) - .subscribe({ - next: () => { - this.ui.success(this.editingId ? 'toast.routerUpdated' : 'toast.routerCreated'); - this.visible = false; - this.load(); - }, - error: (error) => { - this.ui.apiError(error, 'toast.routerSaveFailed'); - } - }); + request$.subscribe({ + next: () => { + this.ui.success(this.editingId ? 'toast.routerUpdated' : 'toast.routerCreated'); + this.visible = false; + this.load(); + }, + complete: () => { + this.saving = false; + } + }); } async remove(id: number) { diff --git a/frontend/src/app/features/settings/settings-page.component.html b/frontend/src/app/features/settings/settings-page.component.html index ed5052d..83210f2 100644 --- a/frontend/src/app/features/settings/settings-page.component.html +++ b/frontend/src/app/features/settings/settings-page.component.html @@ -310,7 +310,7 @@ - +
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 3e5e616..fa263af 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -56,7 +56,6 @@ "currentPassword": "Current password", "newPassword": "New password", "backToLogin": "Back to login", - "backToApp": "Back to app", "loginSubtitle": "Sign in to continue.", "loginFailed": "Login failed", "accountCreated": "Account created", @@ -458,8 +457,6 @@ "archivePrepared": "Archive prepared.", "exportedRouters": "Export completed for {{count}} devices.", "binaryCompletedRouters": "Binary backup completed for {{count}} devices.", - "routerSaveFailed": "Could not save device.", - "requestTimeout": "Request timed out. Check connection and try again.", "routerCreated": "Router created.", "routerUpdated": "Router updated.", "routerDeleted": "Router deleted.", diff --git a/frontend/src/assets/i18n/es.json b/frontend/src/assets/i18n/es.json index 2b3004b..9a0ef55 100644 --- a/frontend/src/assets/i18n/es.json +++ b/frontend/src/assets/i18n/es.json @@ -56,7 +56,6 @@ "currentPassword": "Contraseña actual", "newPassword": "Nueva contraseña", "backToLogin": "Volver al inicio de sesión", - "backToApp": "Volver a la app", "loginSubtitle": "Inicia sesión para continuar.", "loginFailed": "Error de inicio de sesión", "accountCreated": "Cuenta creada", @@ -440,8 +439,6 @@ "archivePrepared": "Archivo preparado.", "exportedRouters": "Exportación completada para {{count}} routers.", "binaryCompletedRouters": "Copia binaria completada para {{count}} routers.", - "routerSaveFailed": "No se pudo guardar el dispositivo.", - "requestTimeout": "Se agotó el tiempo de espera. Comprueba la conexión e inténtalo de nuevo.", "routerCreated": "Router creado.", "routerUpdated": "Router actualizado.", "routerDeleted": "Router eliminado.", diff --git a/frontend/src/assets/i18n/no.json b/frontend/src/assets/i18n/no.json index a5682cc..f937800 100644 --- a/frontend/src/assets/i18n/no.json +++ b/frontend/src/assets/i18n/no.json @@ -56,7 +56,6 @@ "currentPassword": "Nåværende passord", "newPassword": "Nytt passord", "backToLogin": "Tilbake til innlogging", - "backToApp": "Tilbake til appen", "loginSubtitle": "Logg inn for å fortsette.", "loginFailed": "Innlogging mislyktes", "accountCreated": "Konto opprettet", @@ -440,8 +439,6 @@ "archivePrepared": "Arkiv klargjort.", "exportedRouters": "Export fullført for {{count}} rutere.", "binaryCompletedRouters": "Binær backup fullført for {{count}} rutere.", - "routerSaveFailed": "Kunne ikke lagre enheten.", - "requestTimeout": "Tidsavbrudd for forespørselen. Sjekk tilkoblingen og prøv igjen.", "routerCreated": "Ruter opprettet.", "routerUpdated": "Ruter oppdatert.", "routerDeleted": "Ruter slettet.", diff --git a/frontend/src/assets/i18n/pl.json b/frontend/src/assets/i18n/pl.json index 7156eb1..768ec40 100644 --- a/frontend/src/assets/i18n/pl.json +++ b/frontend/src/assets/i18n/pl.json @@ -56,7 +56,6 @@ "currentPassword": "Obecne hasło", "newPassword": "Nowe hasło", "backToLogin": "Powrót do logowania", - "backToApp": "Powrót do aplikacji", "loginSubtitle": "Zaloguj się, aby kontynuować.", "loginFailed": "Logowanie nie powiodło się", "accountCreated": "Konto zostało utworzone", @@ -458,8 +457,6 @@ "archivePrepared": "Archiwum zostało przygotowane.", "exportedRouters": "Wykonano export dla {{count}} urządzeń.", "binaryCompletedRouters": "Wykonano backup binarny dla {{count}} urządzeń.", - "routerSaveFailed": "Nie udało się zapisać urządzenia.", - "requestTimeout": "Przekroczono czas oczekiwania. Sprawdź połączenie i spróbuj ponownie.", "routerCreated": "Urządzenie zostało dodane.", "routerUpdated": "Urządzenie zostało zaktualizowane.", "routerDeleted": "Urządzenie zostało usunięte.", diff --git a/frontend/src/styles/pages.css b/frontend/src/styles/pages.css index 0726ef3..50f8bed 100644 --- a/frontend/src/styles/pages.css +++ b/frontend/src/styles/pages.css @@ -2598,9 +2598,13 @@ app-page-header{ } .repository-table .p-button .p-button-label{ - white-space: normal; - overflow-wrap: anywhere; - word-break: break-word; + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; +} + +.repository-table .p-column-title{ + display: none; } .repository-table .p-button .p-button-icon{ @@ -3528,10 +3532,229 @@ body.dark-theme .device-toggle.is-active{background:linear-gradient(135deg,color min-width: 0; } + .repository-table .p-column-title{ + display: inline-flex; + align-items: center; + } + .repository-table .p-button.table-action-btn, .repository-table .p-button.table-action-btn--wide, .repository-table .p-button.table-action-btn--compact{ width: 100%; justify-content: center; } + + .repository-table .p-button .p-button-label{ + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + } +} + + +.repository-table .table-actions--labels, +.repository-table .table-actions--tight{ + align-items: center; +} + +.repository-table .table-actions--tight{ + flex-wrap: wrap; + overflow-x: visible; +} + +.repository-table .p-button.table-action-btn, +.repository-table .p-button.table-action-btn--wide, +.repository-table .p-button.table-action-btn--compact{ + width: auto; + flex: 0 0 auto; +} + +.repository-table.app-table .p-datatable-thead > tr > th:last-child, +.repository-table.app-table .p-datatable-tbody > tr > td:last-child{ + width: 1%; + white-space: nowrap; +} + + +/* --- table usability fix 2026-04-15 --- */ +.repository-table .p-column-title{display:none !important;} +@media (max-width: 960px){ + .repository-table .p-column-title{display:inline-flex !important;align-items:center;} +} + +.repository-table--files .p-datatable-thead > tr > th:first-child, +.repository-table--files .p-datatable-tbody > tr > td:first-child{ + width: 3rem; + padding-inline: 0.55rem; +} +.repository-table--router-detail .p-datatable-thead > tr > th:first-child, +.repository-table--router-detail .p-datatable-tbody > tr > td:first-child, +.repository-table--diff .p-datatable-thead > tr > th:first-child, +.repository-table--diff .p-datatable-tbody > tr > td:first-child{ + width: auto; + padding-inline: 0.8rem; +} + +.repository-table .table-primary--ellipsis{ + display:block; + max-width:100%; + white-space:nowrap; + overflow:hidden; + text-overflow:ellipsis; +} +.repository-table .table-secondary{ + overflow:hidden; + text-overflow:ellipsis; +} +.repository-table.app-table .p-datatable-thead > tr > th, +.repository-table.app-table .p-datatable-tbody > tr > td{ + min-width:0; +} +.repository-table.app-table .p-datatable-thead > tr > th:last-child, +.repository-table.app-table .p-datatable-tbody > tr > td:last-child{ + width: 280px; + min-width: 280px; + white-space: normal; +} +.repository-table--diff.app-table .p-datatable-thead > tr > th:last-child, +.repository-table--diff.app-table .p-datatable-tbody > tr > td:last-child{ + width: 220px; + min-width: 220px; +} + +.repository-table .table-actions--desktop-row{ + display:flex; + flex-direction:row; + flex-wrap:nowrap; + align-items:center; + gap:0.45rem; + overflow:hidden; +} +.repository-table .table-actions--desktop-row .p-button{ + flex:0 0 auto; +} +.repository-table .table-row-menu{ + display:none; + position:relative; +} +.repository-table .table-row-menu summary{ + display:inline-flex; + align-items:center; + gap:8px; + list-style:none; + cursor:pointer; + padding:8px 12px; + border-radius:12px; + border:1px solid color-mix(in srgb,var(--border-color) 82%, transparent); + background:color-mix(in srgb,var(--surface-2) 92%, transparent); + color:var(--text-main); + font-weight:700; + white-space:nowrap; +} +.repository-table .table-row-menu summary::-webkit-details-marker{display:none;} +.repository-table .table-row-menu__list{ + position:absolute; + right:0; + top:calc(100% + 8px); + z-index:25; + display:grid; + gap:8px; + min-width:220px; + padding:10px; + border-radius:16px; + border:1px solid color-mix(in srgb,var(--border-color) 82%, transparent); + background:color-mix(in srgb,var(--surface-1) 96%, transparent); + box-shadow:var(--shadow-lg); +} +.repository-table .table-row-menu__list .p-button{width:100%;justify-content:center;} + +@media (max-width: 1400px){ + .repository-table--router-detail .table-actions--desktop-row, + .repository-table--diff .table-actions--desktop-row{ + display:none; + } + .repository-table--router-detail .table-row-menu, + .repository-table--diff .table-row-menu{ + display:inline-block; + } +} +@media (max-width: 1520px){ + .repository-table--files .table-actions--desktop-row{ + display:none; + } + .repository-table--files .table-row-menu{ + display:inline-block; + } +} +@media (max-width: 960px){ + .repository-table.app-table .p-datatable-thead > tr > th:last-child, + .repository-table.app-table .p-datatable-tbody > tr > td:last-child, + .repository-table--diff.app-table .p-datatable-thead > tr > th:last-child, + .repository-table--diff.app-table .p-datatable-tbody > tr > td:last-child{ + width:auto; + min-width:0; + } + .repository-table .table-actions--desktop-row, + .repository-table .table-row-menu{ + display:none !important; + } + .repository-table .table-primary--ellipsis, + .repository-table .table-secondary{ + white-space:normal; + overflow:visible; + text-overflow:unset; + } +} + +/* repository table desktop layout fix 2026-04-15 v4 */ +.repository-table--files.app-table .p-datatable-thead > tr > th:nth-child(2), +.repository-table--files.app-table .p-datatable-tbody > tr > td:nth-child(2){ + width: 34%; + max-width: 0; +} +.repository-table--files.app-table .p-datatable-thead > tr > th:nth-child(7), +.repository-table--files.app-table .p-datatable-tbody > tr > td:nth-child(7){ + width: 230px; + min-width: 230px; + white-space: normal; +} +.repository-table--files.app-table .p-datatable-thead > tr > th:nth-child(8), +.repository-table--files.app-table .p-datatable-tbody > tr > td:nth-child(8){ + width: 200px; + min-width: 200px; + white-space: normal; +} +.repository-table--files .table-actions--desktop-row{ + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.45rem; +} +.repository-table--files .table-actions--desktop-row .p-button{ + width: 100%; + justify-content: center; +} +.repository-table--files .table-primary--ellipsis, +.repository-table--files .table-secondary{ + max-width: 100%; +} +@media (max-width: 1520px){ + .repository-table--files .table-actions--desktop-row{ + display: none; + } + .repository-table--files .table-row-menu{ + display: inline-block; + } +} +@media (max-width: 960px){ + .repository-table--files.app-table .p-datatable-thead > tr > th:nth-child(2), + .repository-table--files.app-table .p-datatable-tbody > tr > td:nth-child(2), + .repository-table--files.app-table .p-datatable-thead > tr > th:nth-child(7), + .repository-table--files.app-table .p-datatable-tbody > tr > td:nth-child(7), + .repository-table--files.app-table .p-datatable-thead > tr > th:nth-child(8), + .repository-table--files.app-table .p-datatable-tbody > tr > td:nth-child(8){ + width: auto; + min-width: 0; + max-width: none; + } }