feat: consolidate legacy parity migration baseline

This commit is contained in:
2026-07-20 22:06:12 +09:00
parent 5eb4054120
commit 4a54977341
66 changed files with 5391 additions and 460 deletions

View File

@@ -0,0 +1,191 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private static readonly TimeSpan DatabaseHealthPollingInterval =
TimeSpan.FromSeconds(10);
private readonly SemaphoreSlim _databaseActivityGate = new(1, 1);
private readonly INativeOperatorLogWriter _databaseHealthLogWriter =
new NativeOperatorLogWriter();
private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions =
new();
private int _databaseActivityPriorityWaiterCount;
private CancellationTokenSource? _databaseHealthCheckCancellation;
private Task? _databaseHealthMonitorTask;
private void StartDatabaseHealthMonitor()
{
_databaseHealthMonitorTask ??=
MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
}
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
// WinForms timerAliveChecker first fired after its 10-second interval.
await Task.Delay(DatabaseHealthPollingInterval, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
try
{
await CheckAndLogDatabaseHealthAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
}
}
private async Task CheckAndLogDatabaseHealthAsync(CancellationToken cancellationToken)
{
if (Volatile.Read(ref _playoutBusy) != 0 ||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0 ||
!await _databaseActivityGate.WaitAsync(0, cancellationToken)
.ConfigureAwait(false))
{
return;
}
CancellationTokenSource? healthCancellation = null;
try
{
if (Volatile.Read(ref _playoutBusy) != 0)
{
return;
}
if (_databaseRuntime is null)
{
ObserveDatabaseHealthState(
DataSourceKind.Oracle,
DatabaseHealthState.NotConfigured);
ObserveDatabaseHealthState(
DataSourceKind.MariaDb,
DatabaseHealthState.NotConfigured);
return;
}
healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
Interlocked.Exchange(
ref _databaseHealthCheckCancellation,
healthCancellation);
// Close the race where an operator command starts after this monitor
// acquired the activity gate but before the provider probes begin.
if (Volatile.Read(ref _playoutBusy) != 0 ||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0)
{
healthCancellation.Cancel();
return;
}
var statuses = await _databaseRuntime.HealthService
.CheckAllAsync(healthCancellation.Token)
.ConfigureAwait(false);
foreach (var status in statuses)
{
ObserveDatabaseHealthState(status.Source, status.State);
}
}
catch (OperationCanceledException) when (
cancellationToken.IsCancellationRequested ||
healthCancellation?.IsCancellationRequested == true)
{
// Shutdown and operator work both have priority over diagnostics.
}
catch
{
ObserveDatabaseHealthState(
DataSourceKind.Oracle,
DatabaseHealthState.Unhealthy);
ObserveDatabaseHealthState(
DataSourceKind.MariaDb,
DatabaseHealthState.Unhealthy);
}
finally
{
if (healthCancellation is not null)
{
Interlocked.CompareExchange(
ref _databaseHealthCheckCancellation,
null,
healthCancellation);
healthCancellation.Dispose();
}
_databaseActivityGate.Release();
}
}
private async Task AcquirePriorityDatabaseActivityAsync(
CancellationToken cancellationToken)
{
// Signal priority before inspecting the active probe. This closes both
// sides of the acquisition/publish race:
// * a probe that has not acquired the gate sees the waiter and yields;
// * a probe that owns the gate either sees the waiter after publishing
// its CTS or is cancelled through that already-published CTS.
Interlocked.Increment(ref _databaseActivityPriorityWaiterCount);
try
{
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(cancellationToken)
.ConfigureAwait(false);
}
finally
{
Interlocked.Decrement(ref _databaseActivityPriorityWaiterCount);
}
}
private void CancelActiveDatabaseHealthCheck()
{
var healthCancellation = Volatile.Read(ref _databaseHealthCheckCancellation);
try
{
healthCancellation?.Cancel();
}
catch (ObjectDisposedException)
{
// Probe completion won the race after the reference was read.
}
}
private void ObserveDatabaseHealthState(
DataSourceKind source,
DatabaseHealthState state)
{
if (!_databaseHealthTransitions.TryObserveDatabaseState(
source,
state,
out var record))
{
return;
}
try
{
_ = _databaseHealthLogWriter.TryWrite(record);
}
catch
{
// Logging is best effort and cannot affect DB or playout work.
}
}
}

View File

@@ -178,8 +178,9 @@ public sealed partial class MainWindow
{
case LegacyOperatorPlayoutCommand.Prepare:
var request = gateAPrepareRequest ??
_controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
await CreateFreshPagedPlayoutRequestAsync(
workflow,
cancellationToken).ConfigureAwait(false);
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
@@ -190,8 +191,9 @@ public sealed partial class MainWindow
// candidate) to m_crow and ran ONAirMode + Play, even while another
// row was already on air. Re-read the current selection on every F8;
// a stale explicit PREPARE must never override a later row click.
var takeInRequest = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
var takeInRequest = await CreateFreshPagedPlayoutRequestAsync(
workflow,
cancellationToken).ConfigureAwait(false);
result = await workflow.TakeSelectedAsync(
takeInRequest.Playlist,
takeInRequest.SelectedIndexZeroBased,
@@ -280,6 +282,61 @@ public sealed partial class MainWindow
}
}
private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
LegacyPlayoutWorkflow workflow,
CancellationToken cancellationToken)
{
var fadeDuration = _compositionOptions!.Current.FadeDuration;
var request = _controller.CreatePlayoutRequest(fadeDuration);
// The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
// operator PageN row from a fresh read-only DB preflight before that snapshot
// is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
// already-frozen 1/N values and must not query a second navigation contract.
if (workflow.State.CurrentEntryId is not null)
{
return request;
}
var pagedEntries = request.Playlist
.Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
.ToArray();
if (pagedEntries.Length == 0)
{
return request;
}
var plans = await workflow.PreflightPagePlansAsync(
Array.AsReadOnly(pagedEntries),
cancellationToken).ConfigureAwait(false);
if (plans.Count != pagedEntries.Length)
{
throw new LegacySceneDataException(
"The playlist page preflight did not return every PageN entry.");
}
foreach (var plan in plans)
{
var entry = pagedEntries.SingleOrDefault(candidate =>
string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
plan.PageSize != (int)expectedPageSize ||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
{
throw new LegacySceneDataException(
"The playlist page preflight does not match the operator PageN row.");
}
_controller.ReflectSuccessfulPlayout(
plan.EntryId,
pageIndexZeroBased: 0,
pageCount: plan.PageCount,
synchronizeOperatorSelection: false);
}
return _controller.CreatePlayoutRequest(fadeDuration);
}
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
string capability,
CancellationToken cancellationToken)
@@ -596,9 +653,7 @@ public sealed partial class MainWindow
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
LegacyCutPointerDownIntent or
LegacyCutKeyDownIntent or
LegacyClearCutSelectionIntent or
LegacyCutPointerDownIntent { AllowAppend: true } or
LegacyDropSelectedCutsIntent or
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
@@ -948,6 +1003,7 @@ public sealed partial class MainWindow
}
var intentGateEntered = false;
var databaseGateEntered = false;
var playoutGateEntered = false;
try
{
@@ -958,6 +1014,9 @@ public sealed partial class MainWindow
_intentBusy = true;
QueueOperatorState();
await AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false);
databaseGateEntered = true;
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
playoutGateEntered = true;
Interlocked.Exchange(ref _playoutBusy, 1);
@@ -1058,6 +1117,11 @@ public sealed partial class MainWindow
_playoutCommandGate.Release();
}
if (databaseGateEntered)
{
_databaseActivityGate.Release();
}
if (intentGateEntered)
{
_intentBusy = false;

View File

@@ -22,6 +22,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
@@ -241,6 +242,7 @@ public sealed partial class MainWindow : Window
await InitializeWebViewAsync();
if (!_closing)
{
StartDatabaseHealthMonitor();
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
}
}
@@ -380,15 +382,35 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
var orderedPlayoutIntentEntered = false;
var databaseGateEntered = false;
var dispatchStarted = false;
try
{
// MainForm performed database search synchronously. The busy snapshot
// freezes the Web controls, while this zero-timeout gate prevents an
// already-posted duplicate from growing an unbounded DB request queue.
gateEntered = await _intentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
// freezes the Web controls. Playout commands must retain their order behind
// a preceding local row-selection intent, but only one may wait or execute
// at a time so a double post can never become a queued vendor command.
if (IsOrderedPlayoutIntent(intent))
{
orderedPlayoutIntentEntered = await _orderedPlayoutIntentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
if (orderedPlayoutIntentEntered)
{
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
}
else
{
// Keep the zero-timeout admission used by database and edit intents;
// those requests must not build an unbounded queue behind slow I/O.
gateEntered = await _intentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
}
if (!gateEntered)
{
PostState(IsNamedPlaylistModalIntent(intent)
@@ -514,6 +536,13 @@ public sealed partial class MainWindow : Window
PostState(_controller.Current);
}
await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token);
databaseGateEntered = true;
if (_closing)
{
return;
}
dispatchStarted = true;
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
ObserveOperatorMutationQuarantine(state);
@@ -546,13 +575,26 @@ public sealed partial class MainWindow : Window
_intentBusy = false;
}
if (databaseGateEntered)
{
_databaseActivityGate.Release();
}
if (gateEntered)
{
_intentGate.Release();
}
if (orderedPlayoutIntentEntered)
{
_orderedPlayoutIntentGate.Release();
}
}
}
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
intent is LegacyRefreshNamedPlaylistsIntent or
LegacySelectNamedPlaylistIntent or
@@ -648,7 +690,8 @@ public sealed partial class MainWindow : Window
pointer.Shift,
pointer.X,
pointer.Y,
pointer.TimestampMilliseconds),
pointer.TimestampMilliseconds,
pointer.AllowAppend),
LegacyCutKeyDownIntent key => _controller.CutKeyDown(
key.Key,
key.Control,
@@ -1347,6 +1390,7 @@ public sealed partial class MainWindow : Window
DetachCloseConfirmation();
DetachInteractionMetricsRefresh();
_lifetimeCancellation.Cancel();
CancelActiveDatabaseHealthCheck();
ShutdownPlayoutRuntime();
ShutdownManualListsRuntime();
DataQueryExecutor.Reset();

View File

@@ -6,6 +6,7 @@
const searchButton = document.getElementById("stock-search-button");
const stockResults = document.getElementById("stock-results");
const cutList = document.getElementById("cut-list");
const cutDragFeedback = document.getElementById("cut-drag-feedback");
const playlistRows = document.getElementById("playlist-rows");
const playlistDropZone = document.getElementById("playlist-drop-zone");
const playlistMoveUp = document.getElementById("playlist-move-up");
@@ -123,6 +124,7 @@
let operatorCatalogDraftKey = "";
let manualResultClickTimer = null;
let comparisonTargetClickTimer = null;
let themeResultClickTimer = null;
let manualViResultClickTimer = null;
let operatorCatalogStockClickTimer = null;
let pendingNamedPlaylistCommand = null;
@@ -357,6 +359,8 @@
treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
if (nextTabId !== renderedTreeTabId) {
clearDelayedClick(themeResultClickTimer);
themeResultClickTimer = null;
renderedTreeTabId = nextTabId;
pendingTreeResetTabs.clear();
if (nextTabId) pendingTreeResetTabs.add(nextTabId);
@@ -461,6 +465,14 @@
playout.isTakeOutCompletionPending === true || playout.phase !== "idle"));
}
function isCutSelectionLocked(state) {
const playout = state && state.playout;
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
playout.isPlayCompletionPending === true ||
playout.isTakeOutCompletionPending === true));
}
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
@@ -669,10 +681,15 @@
function clearCutDragGesture(releaseCapture) {
const gesture = cutDragGesture;
if (!gesture) return;
cutDragGesture = null;
gesture.element.classList.remove("dragging");
playlistDropZone.classList.remove("cut-drag-over");
cutDragFeedback.hidden = true;
cutDragFeedback.classList.remove("allowed");
delete cutDragFeedback.dataset.dropAllowed;
cutDragFeedback.style.removeProperty("left");
cutDragFeedback.style.removeProperty("top");
if (!gesture) return;
gesture.element.classList.remove("dragging");
if (releaseCapture !== false &&
typeof gesture.element.hasPointerCapture === "function" &&
gesture.element.hasPointerCapture(gesture.pointerId) &&
@@ -685,6 +702,21 @@
}
}
function showCutDragFeedback(event, allowed) {
cutDragFeedback.hidden = false;
cutDragFeedback.classList.toggle("allowed", allowed);
cutDragFeedback.dataset.dropAllowed = allowed ? "true" : "false";
const gap = 14;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const width = cutDragFeedback.offsetWidth;
const height = cutDragFeedback.offsetHeight;
const left = Math.max(4, Math.min(event.clientX + gap, viewportWidth - width - 4));
const top = Math.max(4, Math.min(event.clientY + gap, viewportHeight - height - 4));
cutDragFeedback.style.left = Math.round(left) + "px";
cutDragFeedback.style.top = Math.round(top) + "px";
}
function beginCutDragGesture(element, event, position) {
clearCutDragGesture();
if (event.button !== 0) return;
@@ -719,9 +751,10 @@
}
if (gesture.started) {
event.preventDefault();
playlistDropZone.classList.toggle(
"cut-drag-over",
!playlistMutationLocked && pointInsideElement(playlistDropZone, event));
const dropAllowed = !playlistMutationLocked &&
pointInsideElement(playlistDropZone, event);
playlistDropZone.classList.toggle("cut-drag-over", dropAllowed);
showCutDragFeedback(event, dropAllowed);
}
}
@@ -790,7 +823,6 @@
element.append(number, label);
element.addEventListener("pointerdown", function (event) {
if (playlistMutationLocked) return event.preventDefault();
if (!cutSelectionEnabled) return event.preventDefault();
focusCutElement(element, false);
const position = pointerPosition(event);
@@ -802,9 +834,12 @@
// equality contract. Drag geometry keeps the raw fractional CSS point.
x: Math.round(position.x),
y: Math.round(position.y),
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)),
allowAppend: !playlistMutationLocked
});
beginCutDragGesture(element, event, position);
if (!playlistMutationLocked) {
beginCutDragGesture(element, event, position);
}
});
element.addEventListener("pointermove", onCutPointerMove);
element.addEventListener("pointerup", onCutPointerUp);
@@ -816,7 +851,6 @@
// The native ListView consumes these keys. Home/End still bubble to the
// document-level MainForm KeyPreview equivalent for its playlist side effect.
event.preventDefault();
if (playlistMutationLocked) return;
if (!cutSelectionEnabled) return;
const target = cutKeyboardTarget(element, event.key);
focusCutElement(target, true);
@@ -831,7 +865,8 @@
function renderCuts(state) {
const cutDragLocked = isOperatorMutationLocked(state);
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked;
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) &&
!isCutSelectionLocked(state);
const focusedCutIndex = state.cutRows.findIndex(function (row) {
return row.isFocused === true;
});
@@ -864,7 +899,9 @@
? 0
: -1;
element.draggable = false;
element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked
? "true"
: "false";
});
while (cutList.children.length > state.cutRows.length) {
@@ -876,7 +913,7 @@
}
cutList.addEventListener("pointerdown", function (event) {
if (event.target !== cutList || playlistMutationLocked) return;
if (event.target !== cutList || !cutSelectionEnabled) return;
send("clear-cut-selection", {});
});
@@ -4000,6 +4037,8 @@
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult && !themeResult.disabled) {
event.preventDefault();
clearDelayedClick(themeResultClickTimer);
themeResultClickTimer = null;
send("activate-theme-result", {
resultId: themeResult.dataset.themeResultId
});
@@ -4260,7 +4299,12 @@
}
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult) {
send("select-theme", { resultId: themeResult.dataset.themeResultId });
clearDelayedClick(themeResultClickTimer);
const resultId = themeResult.dataset.themeResultId;
themeResultClickTimer = window.setTimeout(function () {
themeResultClickTimer = null;
send("select-theme", { resultId: resultId });
}, 250);
return;
}
if (event.target.closest("button[data-theme-search]")) {

View File

@@ -257,6 +257,11 @@
</div>
</div>
<div id="cut-drag-feedback" class="cut-drag-feedback" aria-hidden="true" hidden>
<span class="cut-drag-feedback-icon"></span>
<span>선택 컷 추가</span>
</div>
<script src="legacy-cut-drag.js"></script>
<script src="legacy-modal-focus.js"></script>
<script src="legacy-named-playlist-selection.js"></script>

View File

@@ -84,7 +84,7 @@ button:disabled { color: #555; opacity: 1; }
.manual-financial-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
.manual-financial-dialog h2 { margin: 0; font-size: 18px; }
.manual-financial-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); gap: 7px; padding: 8px; }
.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 7px; padding: 8px; }
.manual-financial-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
.manual-financial-toolbar input, .manual-financial-toolbar button { min-width: 0; height: 30px; }
.manual-financial-body { min-height: 0; display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 8px; }
@@ -144,6 +144,13 @@ button:disabled { color: #555; opacity: 1; }
.cut-row[data-cut-drag-enabled="true"] { cursor: grab; }
.cut-row.dragging { opacity: .58; cursor: grabbing; }
.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; }
.cut-drag-feedback { position: fixed; z-index: 10000; display: flex; align-items: center; gap: 7px; max-width: 260px; padding: 7px 10px; border: 1px solid #8a8a8a; border-radius: 4px; background: rgba(255, 255, 255, .97); box-shadow: 0 3px 10px rgba(0, 0, 0, .28); color: #555; font-size: 13px; font-weight: 700; line-height: 1; pointer-events: none; user-select: none; white-space: nowrap; }
.cut-drag-feedback[hidden] { display: none; }
.cut-drag-feedback-icon { display: inline-flex; width: 17px; height: 17px; align-items: center; justify-content: center; border-radius: 50%; background: #777; color: #fff; font-size: 16px; font-weight: 800; }
.cut-drag-feedback-icon::before { content: "×"; }
.cut-drag-feedback.allowed { border-color: #176b2d; color: #125824; }
.cut-drag-feedback.allowed .cut-drag-feedback-icon { background: #176b2d; }
.cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }