read log files from end, added user management

This commit is contained in:
2026-06-04 13:21:56 -04:00
parent 8a11acfcfd
commit 0796982fd7
8 changed files with 471 additions and 66 deletions
+6 -1
View File
@@ -256,7 +256,12 @@ export default function App() {
</div>
</main>
{isAdmin && showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
{isAdmin && showConfig && (
<ConfigModal
onClose={() => setShowConfig(false)}
currentUserId={user?.id ?? null}
/>
)}
</div>
)
}
+318 -39
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useConfig, useSaveConfig } from '../hooks/useConfig'
import { apiFetch } from '../lib/api'
import { apiFetch, createUser, deleteUser, getUsers, setUserPassword } from '../lib/api'
function useScanPoller(onDone) {
const timerRef = useRef(null)
@@ -42,17 +42,37 @@ function fmtSeconds(s) {
return String(m)
}
export default function ConfigModal({ onClose }) {
function getErrorMessage(err, fallback) {
if (!err?.message) return fallback
try {
const parsed = JSON.parse(err.message)
if (parsed?.error) return parsed.error
} catch {
// Keep original error text when not JSON.
}
return err.message || fallback
}
export default function ConfigModal({ onClose, currentUserId = null }) {
const queryClient = useQueryClient()
const { data: config, isLoading } = useConfig()
const { mutate: save, isPending } = useSaveConfig()
const [activeTab, setActiveTab] = useState('general')
const [form, setForm] = useState({})
const [scanResult, setScanResult] = useState(null)
const [saveError, setSaveError] = useState(null)
const [isRescanning, setIsRescanning] = useState(false)
const [isScanning, setIsScanning] = useState(false)
const [isSavingTimes, setIsSavingTimes] = useState(false)
const [users, setUsers] = useState([])
const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('')
const [usersNotice, setUsersNotice] = useState('')
const [isCreatingUser, setIsCreatingUser] = useState(false)
const [isSettingPassword, setIsSettingPassword] = useState(false)
const [deletingUserId, setDeletingUserId] = useState(null)
const [createForm, setCreateForm] = useState({ username: '', password: '', role: 'viewer' })
const [passwordForm, setPasswordForm] = useState({ userId: '', password: '' })
const scanPoller = useScanPoller(() => {
queryClient.invalidateQueries({ queryKey: ['stats'] })
@@ -80,7 +100,6 @@ export default function ConfigModal({ onClose }) {
function handleSave() {
setSaveError(null)
setScanResult(null)
const payload = {
target_dir: form.target_dir || null,
results_dir: form.results_dir || null,
@@ -111,7 +130,6 @@ export default function ConfigModal({ onClose }) {
async function handleSaveTimes() {
setSaveError(null)
setScanResult(null)
setIsSavingTimes(true)
try {
await apiFetch('/config', {
@@ -134,7 +152,6 @@ export default function ConfigModal({ onClose }) {
async function handleRescanResults() {
setSaveError(null)
setScanResult(null)
setIsRescanning(true)
try {
const data = await apiFetch('/config/rescan-results', { method: 'POST' })
@@ -157,16 +174,133 @@ export default function ConfigModal({ onClose }) {
{ key: 'avg_time_p3p', label: 'P3P avg time (min)' },
]
const loadUsers = useCallback(async () => {
setUsersLoading(true)
setUsersError('')
try {
const data = await getUsers()
setUsers(Array.isArray(data) ? data : [])
setPasswordForm((prev) => ({
...prev,
userId: prev.userId || (data?.[0]?.id ? String(data[0].id) : ''),
}))
} catch (err) {
setUsersError(getErrorMessage(err, 'Failed to load users'))
} finally {
setUsersLoading(false)
}
}, [])
useEffect(() => {
if (activeTab === 'users') {
loadUsers()
}
}, [activeTab, loadUsers])
async function handleCreateUser() {
setUsersError('')
setUsersNotice('')
const username = createForm.username.trim()
const password = createForm.password
const role = createForm.role
if (!username || !password) {
setUsersError('Username and password are required')
return
}
setIsCreatingUser(true)
try {
await createUser({ username, password, role })
setUsersNotice(`User ${username} created`)
setCreateForm({ username: '', password: '', role: 'viewer' })
await loadUsers()
} catch (err) {
setUsersError(getErrorMessage(err, 'Failed to create user'))
} finally {
setIsCreatingUser(false)
}
}
async function handleSetPassword() {
setUsersError('')
setUsersNotice('')
const userId = parseInt(passwordForm.userId, 10)
const password = passwordForm.password
if (!userId || !password) {
setUsersError('Select a user and provide a new password')
return
}
setIsSettingPassword(true)
try {
await setUserPassword(userId, { password })
const target = users.find((u) => u.id === userId)
setUsersNotice(`Password updated for ${target?.username || `user #${userId}`}`)
setPasswordForm((prev) => ({ ...prev, password: '' }))
} catch (err) {
setUsersError(getErrorMessage(err, 'Failed to set password'))
} finally {
setIsSettingPassword(false)
}
}
async function handleDeleteUser(userId, username) {
setUsersError('')
setUsersNotice('')
const confirmed = window.confirm(`Delete user ${username}? This cannot be undone.`)
if (!confirmed) return
setDeletingUserId(userId)
try {
await deleteUser(userId)
setUsersNotice(`User ${username} deleted`)
setPasswordForm((prev) => ({
...prev,
userId: prev.userId === String(userId) ? '' : prev.userId,
}))
await loadUsers()
} catch (err) {
setUsersError(getErrorMessage(err, 'Failed to delete user'))
} finally {
setDeletingUserId(null)
}
}
return (
<>
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl">
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-3xl mx-4 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
<h2 className="text-slate-100 font-semibold">Settings</h2>
<div className="flex items-center gap-2">
<h2 className="text-slate-100 font-semibold">Settings</h2>
<span className="text-slate-500 text-xs">Admin</span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>
</div>
<div className="px-5 pt-4">
<div className="inline-flex rounded-lg border border-slate-700 overflow-hidden">
<button
onClick={() => setActiveTab('general')}
className={`px-3 py-1.5 text-sm ${activeTab === 'general' ? 'bg-slate-700 text-slate-100' : 'bg-slate-900 text-slate-300 hover:bg-slate-800'}`}
>
General
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-3 py-1.5 text-sm border-l border-slate-700 ${activeTab === 'users' ? 'bg-slate-700 text-slate-100' : 'bg-slate-900 text-slate-300 hover:bg-slate-800'}`}
>
User Management
</button>
</div>
</div>
<div className="px-5 py-4 space-y-6">
{/* Error banner */}
{saveError && (
@@ -175,6 +309,18 @@ export default function ConfigModal({ onClose }) {
</div>
)}
{usersError && activeTab === 'users' && (
<div className="bg-red-950/50 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm">
{usersError}
</div>
)}
{usersNotice && activeTab === 'users' && (
<div className="bg-emerald-950/50 border border-emerald-700 rounded-lg px-4 py-3 text-emerald-300 text-sm">
{usersNotice}
</div>
)}
{/* Scanning indicator (save triggered a scan) */}
{isScanning && (
<div className="bg-blue-950/50 border border-blue-700 rounded-lg px-4 py-3 text-blue-300 text-sm flex items-center gap-2">
@@ -191,9 +337,9 @@ export default function ConfigModal({ onClose }) {
</div>
)}
{isLoading ? (
{activeTab === 'general' && isLoading ? (
<p className="text-slate-500 text-sm">Loading</p>
) : (
) : activeTab === 'general' ? (
<>
{/* Directories */}
<section>
@@ -285,40 +431,173 @@ export default function ConfigModal({ onClose }) {
</div>
</section>
</>
) : (
<>
<section>
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Current Users</h3>
{usersLoading ? (
<p className="text-slate-500 text-sm">Loading users</p>
) : (
<div className="overflow-x-auto border border-slate-800 rounded-lg">
<table className="w-full text-sm">
<thead className="bg-slate-800/50 text-slate-300">
<tr>
<th className="text-left px-3 py-2 font-medium">Username</th>
<th className="text-left px-3 py-2 font-medium">Role</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-left px-3 py-2 font-medium">Created</th>
<th className="text-left px-3 py-2 font-medium">Action</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-slate-800 text-slate-200">
<td className="px-3 py-2">{u.username}</td>
<td className="px-3 py-2 uppercase text-xs tracking-wide">{u.role}</td>
<td className="px-3 py-2">{u.is_active ? 'Active' : 'Inactive'}</td>
<td className="px-3 py-2 text-slate-400">{u.created_at ?? '-'}</td>
<td className="px-3 py-2">
{u.id === currentUserId ? (
<span className="inline-block px-2.5 py-1 text-xs rounded border border-slate-700 text-slate-400">
Current User
</span>
) : (
<button
onClick={() => handleDeleteUser(u.id, u.username)}
disabled={Boolean(deletingUserId) || isCreatingUser || isSettingPassword}
className="px-2.5 py-1 text-xs rounded border border-red-700 text-red-300 hover:bg-red-950/40 disabled:opacity-50 transition-colors"
>
{deletingUserId === u.id ? 'Deleting...' : 'Delete'}
</button>
)}
</td>
</tr>
))}
{!users.length && (
<tr>
<td colSpan={5} className="px-3 py-4 text-slate-500">No users found.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</section>
<section>
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Create User</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input
type="text"
value={createForm.username}
onChange={(e) => setCreateForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="Username"
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Password"
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<select
value={createForm.role}
onChange={(e) => setCreateForm((prev) => ({ ...prev, role: e.target.value }))}
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="mt-3 flex justify-end">
<button
onClick={handleCreateUser}
disabled={isCreatingUser || isSettingPassword || Boolean(deletingUserId)}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isCreatingUser ? 'Creating…' : 'Create User'}
</button>
</div>
</section>
<section>
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Set User Password</h3>
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-center">
<select
value={passwordForm.userId}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, userId: e.target.value }))}
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
>
<option value="">Select user</option>
{users.map((u) => (
<option key={u.id} value={String(u.id)}>{u.username} ({u.role})</option>
))}
</select>
<input
type="password"
value={passwordForm.password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="New password"
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<button
onClick={handleSetPassword}
disabled={isSettingPassword || isCreatingUser || Boolean(deletingUserId)}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isSettingPassword ? 'Saving…' : 'Set Password'}
</button>
</div>
</section>
</>
)}
</div>
{/* Footer */}
<div className="flex justify-between gap-2 px-5 py-4 border-t border-slate-800">
<button
onClick={handleRescanResults}
disabled={isRescanning || isPending || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isRescanning ? 'Rescanning…' : 'Save Results'}
</button>
<div className="flex gap-2">
<button
onClick={handleSaveTimes}
disabled={isSavingTimes || isPending || isRescanning}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isSavingTimes ? 'Saving…' : 'Save Times'}
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isPending || isRescanning || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isPending ? 'Saving…' : 'Save All'}
</button>
</div>
{activeTab === 'general' ? (
<>
<button
onClick={handleRescanResults}
disabled={isRescanning || isPending || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isRescanning ? 'Rescanning…' : 'Save Results'}
</button>
<div className="flex gap-2">
<button
onClick={handleSaveTimes}
disabled={isSavingTimes || isPending || isRescanning}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isSavingTimes ? 'Saving…' : 'Save Times'}
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isPending || isRescanning || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isPending ? 'Saving…' : 'Save All'}
</button>
</div>
</>
) : (
<div className="ml-auto">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
>
Close
</button>
</div>
)}
</div>
</div>
</div>
+4
View File
@@ -37,6 +37,10 @@ export async function apiFetch(path, options = {}) {
export const login = (body) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(body) })
export const getMe = () => apiFetch('/auth/me')
export const getUsers = () => apiFetch('/users')
export const createUser = (body) => apiFetch('/users', { method: 'POST', body: JSON.stringify(body) })
export const setUserPassword = (userId, body) => apiFetch(`/users/${userId}/password`, { method: 'POST', body: JSON.stringify(body) })
export const deleteUser = (userId) => apiFetch(`/users/${userId}`, { method: 'DELETE' })
export const getStats = () => apiFetch('/stats')
export const getTests = (params = {}) => {
+48
View File
@@ -12,9 +12,11 @@ from flask_cors import CORS
from werkzeug.security import check_password_hash, generate_password_hash
from db_py import (
count_admin_users,
count_tests,
count_users,
create_user,
delete_user,
del_config,
get_all_tests,
get_all_users,
@@ -23,6 +25,7 @@ from db_py import (
get_user_by_username,
set_config,
clear_users,
update_user_password,
)
from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only
from watcher import start_results_watchers, stop_results_watchers
@@ -404,6 +407,51 @@ def create_user_route():
return jsonify({"id": user_id, "username": username, "role": role, "is_active": 1}), 201
@app.post("/api/users/<int:user_id>/password")
@require_auth
@require_role("admin")
def set_user_password_route(user_id):
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
password = body.get("password") or ""
if not password:
return jsonify({"error": "Password is required"}), 400
user = get_user_by_id(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
updated = update_user_password(user_id, generate_password_hash(password))
if updated == 0:
return jsonify({"error": "User not found"}), 404
return jsonify({"ok": True, "id": user_id})
@app.delete("/api/users/<int:user_id>")
@require_auth
@require_role("admin")
def delete_user_route(user_id):
user = get_user_by_id(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
current_user = getattr(g, "current_user", None) or {}
if current_user.get("id") == user_id:
return jsonify({"error": "You cannot delete your own account"}), 400
if user.get("role") == "admin" and count_admin_users() <= 1:
return jsonify({"error": "Cannot delete the last active admin"}), 400
deleted = delete_user(user_id)
if deleted == 0:
return jsonify({"error": "User not found"}), 404
return jsonify({"ok": True, "id": user_id})
@app.get("/api/config")
@require_auth
@require_role("admin")
+37
View File
@@ -452,6 +452,43 @@ def create_user(username, password_hash, role="viewer", is_active=1):
return cursor.lastrowid
def update_user_password(user_id, password_hash):
with _tx():
cursor = _conn.execute(
"""
UPDATE users
SET password_hash = ?
WHERE id = ?
""",
(password_hash, user_id),
)
return cursor.rowcount
def delete_user(user_id):
with _tx():
cursor = _conn.execute(
"""
DELETE FROM users
WHERE id = ?
""",
(user_id,),
)
return cursor.rowcount
def count_admin_users():
with _lock:
row = _conn.execute(
"""
SELECT COUNT(*) AS n
FROM users
WHERE role = 'admin' AND is_active = 1
"""
).fetchone()
return row["n"]
def count_users():
with _lock:
row = _conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()
+14
View File
@@ -1,5 +1,6 @@
import re
_DEFAULT_SCAN_EXCLUSIONS = ""
INTERFERENCE_TYPES = {"COE", "P2P", "P3P"}
DIRECTION_TYPES = {"UL", "DL"}
@@ -67,4 +68,17 @@ def parse_timestamp(filename):
second = parts[5] if len(parts) > 5 else "00"
return f"{year}-{month}-{day}T{hour}:{minute}:{second}"
def parse_deleted_result_dir_name(dir_name):
segments = re.split(r"[_\-]", dir_name)
test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None)
device = next((s for s in segments if re.match(r"^CGW\d+$", s, re.IGNORECASE)), None)
return test_id, device
def parse_scan_exclusions(value):
raw = value if value not in (None, "") else _DEFAULT_SCAN_EXCLUSIONS
if raw == "" or raw is None:
return None
parts = re.split(r"[,\n;]", str(raw))
return {token.strip().upper() for token in parts if token.strip()}
+41 -25
View File
@@ -18,22 +18,16 @@ from parser import (
parse_result_filename,
parse_target_filename,
parse_timestamp,
parse_scan_exclusions,
)
_SMB_SESSIONS = set()
_WIN_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
_SCAN_STATE_LOCK = threading.Lock()
_ACTIVE_SCAN_COUNT = 0
_DEFAULT_SCAN_EXCLUSIONS = ""
_ELAPSED_TIME_LINE_RE = re.compile(r"Elapsed\s+time\s*:\s*(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)", re.IGNORECASE)
def _parse_scan_exclusions(value):
raw = value if value not in (None, "") else _DEFAULT_SCAN_EXCLUSIONS
if raw == "" or raw is None:
return None
parts = re.split(r"[,\n;]", str(raw))
return {token.strip().upper() for token in parts if token.strip()}
def _build_match_tokens(parsed):
@@ -299,22 +293,51 @@ def _parse_elapsed_time_to_seconds(line):
seconds = float(match.group(3))
return int(hours * 3600 + minutes * 60 + seconds)
def extract_elapsed_seconds_from_logs(result_dir_path, files):
elapsed_seconds = None
def extract_elapsed_seconds(result_dir_path, files):
log_files = [name for name in files if _is_candidate_log_file(name)]
for log_name in log_files:
log_path = _join_path(result_dir_path, log_name)
try:
for line in _read_text_lines(log_path):
parsed = _parse_elapsed_time_to_seconds(line)
if parsed is not None:
elapsed_seconds = parsed
# Read the log file in reverse to find the most recent "Elapsed time" line
path = _normalize_input_path(log_path)
chunk_size = 8192
if _is_unc_path(path):
_register_smb_session_if_needed(path)
file_obj = smbclient.open_file(path, mode="rb")
else:
file_obj = open(path, "rb")
with file_obj as fh:
fh.seek(0, os.SEEK_END)
file_size = fh.tell()
position = file_size
carry = b""
while position > 0:
read_size = min(chunk_size, position)
position -= read_size
fh.seek(position)
chunk = fh.read(read_size)
data = chunk + carry
lines = data.split(b"\n")
carry = lines[0]
for raw_line in reversed(lines[1:]):
parsed = _parse_elapsed_time_to_seconds(raw_line.decode("utf-8", errors="ignore"))
if parsed is not None:
return parsed
if carry:
parsed = _parse_elapsed_time_to_seconds(carry.decode("utf-8", errors="ignore"))
if parsed is not None:
return parsed
except OSError as exc:
print(f"[scanner] Cannot read log file {log_path}: {exc}")
return elapsed_seconds
return None
def scan_results_only(results_dir, results_dir_ref):
@@ -374,7 +397,7 @@ def full_scan(target_dir, results_dir, results_dir_ref):
def scan_targets(target_dir):
target_dir = _normalize_input_path(target_dir)
exclusions = _parse_scan_exclusions(get_config("scan_exclusions"))
exclusions = parse_scan_exclusions(get_config("scan_exclusions"))
batch_tests = []
try:
@@ -484,7 +507,7 @@ def process_result_dir(results_dir, result_dir_name):
print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}")
return None
elapsed_seconds = extract_elapsed_seconds_from_logs(result_dir_path, files)
elapsed_seconds = extract_elapsed_seconds(result_dir_path, files)
measurement_db = next((name for name in files if name.lower() == "measurement.db"), None)
if not measurement_db:
@@ -514,13 +537,6 @@ def process_result_dir(results_dir, result_dir_name):
}
def parse_deleted_result_dir_name(dir_name):
segments = re.split(r"[_\-]", dir_name)
test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None)
device = next((s for s in segments if re.match(r"^CGW\d+$", s, re.IGNORECASE)), None)
return test_id, device
def extract_measurement_data(db_file_path):
result = {"duration_seconds": None, "tputResults": []}
local_db_path = None
+3 -1
View File
@@ -10,7 +10,9 @@ from db_py import (
update_all_p2p_coe_pairs_sql,
update_all_p3p_pairs_sql,
)
from scanner import parse_deleted_result_dir_name, process_result_dir, resolve_runtime_path
from scanner import process_result_dir, resolve_runtime_path
from parser import parse_deleted_result_dir_name
_OBSERVER = None