feat(vikon): improve part update UI with per-part status indicators and Russian messages
This commit is contained in:
@@ -1 +1 @@
|
||||
{"version":2,"defects":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":7},"times":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":0.017,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":0.027,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":0.011,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":0,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":0.002,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_requires_authentication":0.071,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":0.02,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":0.008,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_1":0.022,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_negative_1":0.001}}
|
||||
{"version":2,"defects":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":8,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":8,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":7,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":7},"times":{"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_replaces_existing_entry":0.005,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_creates_new_entry_when_not_exists":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_atomic_swap_returns_false_on_path_traversal":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_completed":0.027,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_failed":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_timeout_after_max_attempts":0.002,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_module_id":0.003,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_rejects_invalid_part":0.011,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_requests_generation_and_polls_status":0.003,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_generation_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\UpdatePartActionTest::test_throws_on_poll_failure":0.001,"App\\Containers\\VikonIntegration\\Tests\\Unit\\FilesystemTaskTest::test_restore_after_fail_removes_new_and_restores_old":0.004,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_requires_authentication":0.073,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_module_id":0.025,"App\\Containers\\VikonIntegration\\Tests\\Feature\\UpdatePartTest::test_update_part_validates_part":0.009,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_completed_when_status_is_1":0.026,"App\\Containers\\VikonIntegration\\Tests\\Unit\\PollPartStatusTaskTest::test_returns_failed_when_status_is_negative_1":0}}
|
||||
@@ -22,7 +22,7 @@ class UpdatePartAction
|
||||
|
||||
public function run(int $moduleId, string $part, string $accessToken): array
|
||||
{
|
||||
$config = $this->modulesConfig[$moduleId] ?? throw new \RuntimeException("Unknown module: {$moduleId}");
|
||||
$config = $this->modulesConfig[$moduleId] ?? throw new \RuntimeException("Неизвестный модуль: {$moduleId}");
|
||||
|
||||
Log::info('Vikon: starting part update', ['module' => $moduleId, 'part' => $part]);
|
||||
|
||||
@@ -35,7 +35,7 @@ class UpdatePartAction
|
||||
$genBody = $genResponse->json();
|
||||
|
||||
if (empty($genBody['operation_identity'])) {
|
||||
throw new \RuntimeException('Failed to request part generation: ' . ($genBody['message'] ?? 'Unknown'));
|
||||
throw new \RuntimeException('Не удалось запросить генерацию: ' . ($genBody['message'] ?? 'Неизвестная ошибка'));
|
||||
}
|
||||
|
||||
$operationIdentity = $genBody['operation_identity'];
|
||||
@@ -47,10 +47,10 @@ class UpdatePartAction
|
||||
|
||||
if ($pollResult['status'] !== 'completed') {
|
||||
$error = $pollResult['error'] ?? $pollResult['status'];
|
||||
throw new \RuntimeException("Part generation failed: {$error}");
|
||||
throw new \RuntimeException("Ошибка генерации: {$error}");
|
||||
}
|
||||
|
||||
// Step 3: Check result (API returns HTTP 200 on success, no JSON body needed)
|
||||
// Step 3: Check result
|
||||
$checkResponse = $this->http->getWithToken(
|
||||
"pull_updates/checkPartGenerationByNewCoreResultJson?operation_identity={$operationIdentity}&part={$part}",
|
||||
$accessToken
|
||||
@@ -58,7 +58,7 @@ class UpdatePartAction
|
||||
|
||||
if ($checkResponse->failed()) {
|
||||
$body = $checkResponse->json([]);
|
||||
throw new \RuntimeException('Part not ready: ' . ($body['message'] ?? 'HTTP ' . $checkResponse->status()));
|
||||
throw new \RuntimeException('Раздел не готов: ' . ($body['message'] ?? 'HTTP ' . $checkResponse->status()));
|
||||
}
|
||||
|
||||
// Step 4: Download ZIP
|
||||
@@ -75,7 +75,7 @@ class UpdatePartAction
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFile) !== true) {
|
||||
throw new \RuntimeException('Failed to open part ZIP');
|
||||
throw new \RuntimeException('Не удалось открыть ZIP-архив');
|
||||
}
|
||||
$zip->extractTo($tempPath);
|
||||
$zip->close();
|
||||
@@ -85,7 +85,7 @@ class UpdatePartAction
|
||||
$blocked = $this->fs->validateFileTypes($tempPath);
|
||||
if (!empty($blocked)) {
|
||||
File::deleteDirectory($tempPath);
|
||||
throw new \RuntimeException('Blocked files in ZIP: ' . implode(', ', $blocked));
|
||||
throw new \RuntimeException('Запрещённые файлы в архиве: ' . implode(', ', $blocked));
|
||||
}
|
||||
|
||||
$modulePath = $this->basePath . '/' . $config['path'];
|
||||
@@ -94,7 +94,7 @@ class UpdatePartAction
|
||||
// Step 5: Apply
|
||||
$syncedCount = $this->applyPart($part, $tempPath, $modulePath, $moduleId, $config);
|
||||
} finally {
|
||||
// Step 6: Clean temp (always runs, even on exception)
|
||||
// Step 6: Clean temp
|
||||
File::deleteDirectory($tempPath);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class UpdatePartAction
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Part '{$part}' updated successfully.",
|
||||
'message' => "Раздел «{$part}» обновлён.",
|
||||
'synced_count' => $syncedCount,
|
||||
];
|
||||
}
|
||||
@@ -138,13 +138,13 @@ class UpdatePartAction
|
||||
}
|
||||
|
||||
if (!File::exists($partSource)) {
|
||||
throw new \RuntimeException("Part directory not found in ZIP: {$part}");
|
||||
throw new \RuntimeException("Директория «{$part}» не найдена в архиве");
|
||||
}
|
||||
|
||||
$result = $this->fs->atomicSwap($partSource, $partTarget, $modulePath, $moduleId);
|
||||
if (!$result) {
|
||||
$this->fs->restoreAfterFail($modulePath, [$part], $moduleId);
|
||||
throw new \RuntimeException("Failed to apply part: {$part}");
|
||||
throw new \RuntimeException("Не удалось применить раздел: {$part}");
|
||||
}
|
||||
|
||||
return 1;
|
||||
@@ -164,7 +164,7 @@ class UpdatePartAction
|
||||
}
|
||||
|
||||
if (!File::exists($abiturSource)) {
|
||||
throw new \RuntimeException('ABITUR directory not found in ZIP');
|
||||
throw new \RuntimeException('Директория abitur не найдена в архиве');
|
||||
}
|
||||
|
||||
$entries = File::allFiles($abiturSource);
|
||||
@@ -188,7 +188,7 @@ class UpdatePartAction
|
||||
|
||||
if (!$result) {
|
||||
$this->fs->restoreAfterFail($modulePath, ['abitur'], $moduleId);
|
||||
throw new \RuntimeException("Failed to sync ABITUR file: {$relative}");
|
||||
throw new \RuntimeException("Ошибка синхронизации файла: {$relative}");
|
||||
}
|
||||
|
||||
$synced++;
|
||||
|
||||
@@ -47,7 +47,7 @@ class UpdatePartActionTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Unknown module: 999');
|
||||
$this->expectExceptionMessage('Неизвестный модуль: 999');
|
||||
$action->run(999, 'common', 'token');
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class UpdatePartActionTest extends TestCase
|
||||
$result = $action->run(1, 'common', 'token');
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals("Part 'common' updated successfully.", $result['message']);
|
||||
$this->assertEquals("Раздел «common» обновлён.", $result['message']);
|
||||
}
|
||||
|
||||
public function test_throws_on_generation_failure(): void
|
||||
@@ -129,7 +129,7 @@ class UpdatePartActionTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Failed to request part generation');
|
||||
$this->expectExceptionMessage('Не удалось запросить генерацию');
|
||||
$action->run(1, 'common', 'token');
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ class UpdatePartActionTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Part generation failed: Server error');
|
||||
$this->expectExceptionMessage('Ошибка генерации: Server error');
|
||||
$action->run(1, 'common', 'token');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,18 +89,25 @@
|
||||
<input type="checkbox" :checked="isAllPartsSelected(id)" @change="toggleAllParts(id)" class="rounded" />
|
||||
Выбрать все
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 pl-5">
|
||||
<label v-for="p in parts[id]?.parts || []" :key="p.id" class="flex items-center gap-1.5 text-sm" :class="p.access ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'">
|
||||
<input type="checkbox" :value="p.id" v-model="selectedParts[id]" :disabled="!p.access" class="rounded" />
|
||||
{{ p.name }}
|
||||
</label>
|
||||
<div class="pl-5 space-y-1">
|
||||
<div v-for="p in parts[id]?.parts || []" :key="p.id"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="p.access ? '' : 'opacity-50'">
|
||||
<input type="checkbox" :value="p.id" v-model="selectedParts[id]" :disabled="!p.access || !!partStatus[`${id}-${p.id}`]" class="rounded" />
|
||||
<span class="flex-1" :class="p.access ? '' : 'cursor-not-allowed'">{{ p.name }}</span>
|
||||
<span v-if="partStatus[`${id}-${p.id}`] === 'loading'" class="text-primary">
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
</span>
|
||||
<span v-else-if="partStatus[`${id}-${p.id}`] === 'success'" class="text-green-600 text-xs">✓</span>
|
||||
<span v-else-if="partStatus[`${id}-${p.id}`] === 'error'" class="text-red-600 text-xs" :title="partErrors[`${id}-${p.id}`]">✗</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="updateSelectedParts(id)"
|
||||
:disabled="!selectedParts[id]?.length || updatingPart"
|
||||
:disabled="!selectedParts[id]?.length || updatingPart === id"
|
||||
class="text-sm bg-surface border border-layer-line px-3 py-1.5 rounded-md hover:bg-muted-hover disabled:opacity-50"
|
||||
>
|
||||
<span v-if="updatingPart">Обновление...</span>
|
||||
<span v-if="updatingPart === id">Обновление...</span>
|
||||
<span v-else>Обновить выбранные ({{ selectedParts[id]?.length || 0 }})</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -120,18 +127,6 @@
|
||||
<p class="text-xs text-muted-foreground-1 mt-2">{{ progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Результаты обновления частей -->
|
||||
<div v-if="partResults.length" class="bg-layer border border-layer-line rounded-lg p-4 space-y-2">
|
||||
<div v-for="(r, i) in partResults" :key="i" class="text-sm flex items-center gap-2">
|
||||
<span :class="r.success ? 'text-green-600' : 'text-red-600'">
|
||||
{{ r.success ? '✓' : '✗' }}
|
||||
</span>
|
||||
<span :class="r.success ? 'text-green-600' : 'text-red-600'">
|
||||
{{ r.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ошибка -->
|
||||
<div v-if="updateError" class="bg-layer border border-red-200 rounded-lg p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -186,7 +181,8 @@ if (props.parts) {
|
||||
}
|
||||
}
|
||||
const updatingPart = ref(null);
|
||||
const partResults = ref([]);
|
||||
const partStatus = ref({});
|
||||
const partErrors = ref({});
|
||||
|
||||
const authUrl = computed(() => {
|
||||
const redirect = encodeURIComponent(`${window.location.origin}/vikon_core/update/index.php`);
|
||||
@@ -302,21 +298,26 @@ async function updateSelectedParts(moduleId) {
|
||||
if (!partsToUpdate.length) return;
|
||||
|
||||
updatingPart.value = moduleId;
|
||||
partResults.value = [];
|
||||
// Clear previous statuses for this module
|
||||
for (const part of partsToUpdate) {
|
||||
delete partStatus.value[`${moduleId}-${part}`];
|
||||
delete partErrors.value[`${moduleId}-${part}`];
|
||||
}
|
||||
|
||||
for (const part of partsToUpdate) {
|
||||
const key = `${moduleId}-${part}`;
|
||||
partStatus.value[key] = 'loading';
|
||||
partErrors.value[key] = null;
|
||||
|
||||
try {
|
||||
const res = await axios.post(route('dashboard.vikon-updates.update-part'), {
|
||||
await axios.post(route('dashboard.vikon-updates.update-part'), {
|
||||
module_id: moduleId,
|
||||
part,
|
||||
});
|
||||
partResults.value.push(res.data);
|
||||
partStatus.value[key] = 'success';
|
||||
} catch (e) {
|
||||
partResults.value.push({
|
||||
success: false,
|
||||
message: e.response?.data?.message || e.message,
|
||||
part,
|
||||
});
|
||||
partStatus.value[key] = 'error';
|
||||
partErrors.value[key] = e.response?.data?.message || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user