mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
fix: complete audit follow-ups and add regression coverage
Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
This commit is contained in:
1 parent
aae180aaef
commit
d67e5d07d0
72 files changed
+1813
-526
No files matched your search
@@ -155,3 +155,7 @@ SERVERKIT_GITHUB_REPO=jhd3197/ServerKit
|
||||
|
||||
# Mark this panel as a staging install (reported by /health).
|
||||
# SERVERKIT_STAGING=false
|
||||
|
||||
# Add request duration, SQL duration and statement count to Server-Timing.
|
||||
# Temporary local/staging diagnosis; disabled by default. No SQL text is emitted.
|
||||
# SERVERKIT_PROFILE_REQUESTS=false
|
||||
@@ -50,8 +50,8 @@ jobs:
|
||||
run: python tests/check_test_count.py
|
||||
|
||||
# Sharded because this job WAS the entire wait: 21m50s of a ~22m pipeline,
|
||||
# while every other workflow finished inside a minute. Splitting the 3173
|
||||
# tests over 4 runners cuts the critical path to roughly a quarter.
|
||||
# while every other workflow finished inside a minute. Splitting the suite
|
||||
# over 4 runners cuts the critical path to roughly a quarter.
|
||||
#
|
||||
# Sharding rather than pytest-xdist is deliberate: each shard is its own VM,
|
||||
# so the process-shared state that makes in-process parallelism unsafe here
|
||||
@@ -86,9 +86,9 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-split
|
||||
- name: Run tests (shard ${{ matrix.group }} of 4)
|
||||
# Scoped to `tests` rather than a bare `pytest`. Identical here (3173
|
||||
# either way, since backend/dev-data/ is gitignored and absent from a
|
||||
# CI checkout), but it makes the command reproducible on a dev box: a
|
||||
# Scoped to `tests` rather than a bare `pytest`. This collects the same
|
||||
# suite in CI (backend/dev-data/ is gitignored), while keeping the
|
||||
# command reproducible on a dev box: a
|
||||
# bare pytest there tries to collect the locally deployed apps under
|
||||
# backend/dev-data/ and dies during collection. Copy this line verbatim
|
||||
# to debug a red shard locally.
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
name: Frontend CI
|
||||
|
||||
# The frontend's lint gate ran nowhere until now — `npm run lint` was in
|
||||
# package.json and in CLAUDE.md, but no workflow ever invoked it. That silently
|
||||
# unenforced three project-specific checkers that exist precisely because a
|
||||
# human review keeps missing what they catch:
|
||||
#
|
||||
# check-settings-index every Settings tab has a search-index entry
|
||||
# check-theme-tokens the theme-token whitelist stays in 3-way sync
|
||||
# check-html-sinks every raw-HTML sink is sanitized or annotated (XSS)
|
||||
#
|
||||
# `npm run lint` and `npm test` run here. The test suite (`node --test`,
|
||||
# 141 assertions over pure-logic modules) sat invocable-but-uninvoked the
|
||||
# same way lint once did — written insurance no workflow cashed in. The
|
||||
# frontend is already COMPILED in CI by Release Build Smoke Test, whose
|
||||
# scripts/build-release.sh does `npm ci && npm run build` — adding a build
|
||||
# job here would just duplicate that.
|
||||
# Run the warning ratchet, repository integrity checks, unit tests and browser
|
||||
# regressions. Release Build Smoke Test separately compiles the production app.
|
||||
#
|
||||
# backend/app/** is in the paths because check-html-sinks scans it too (for
|
||||
# `|safe`, `Markup(`, `render_template_string`), so a backend-only commit can
|
||||
@@ -52,23 +39,32 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
- name: Lint
|
||||
# eslint + the three checkers, chained by the package.json script.
|
||||
# Currently 926 warnings / 0 errors, and eslint exits 0 on warnings —
|
||||
# so this gates on errors only. If you ever want the warning count
|
||||
# ratcheted the way backend/tests/BASELINE_COUNT ratchets test count,
|
||||
# add --max-warnings=<N> here rather than mass-fixing in one commit.
|
||||
# Reject errors and warning growth by file/rule, then run all integrity
|
||||
# checks chained by package.json. Keep the baseline reviewed in Git.
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Unit tests
|
||||
# node --test over src/**/__tests__ — pure-logic modules, no jsdom.
|
||||
working-directory: frontend
|
||||
run: npm test
|
||||
- name: Install browser for Settings regressions
|
||||
- name: Install browser for regressions
|
||||
working-directory: frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Settings browser regressions
|
||||
working-directory: frontend
|
||||
run: npm run test:browser
|
||||
- name: Shared controls browser regressions
|
||||
working-directory: frontend
|
||||
run: npm run test:controls
|
||||
- name: Metrics and widget browser regressions
|
||||
working-directory: frontend
|
||||
run: npm run test:metrics
|
||||
- name: Authentication and layout hooks browser regressions
|
||||
working-directory: frontend
|
||||
run: npm run test:hooks
|
||||
- name: Backup request and form browser regressions
|
||||
working-directory: frontend
|
||||
run: npm run test:backups
|
||||
- name: Upload Settings screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -16,8 +16,8 @@ on:
|
||||
- 'backend/tests/test_utils_system*.py'
|
||||
|
||||
# The mocked `unit-tests` job that used to lead this file was removed: it ran
|
||||
# `pytest tests/test_utils_system.py` (44 tests), and Backend CI's bare
|
||||
# `pytest -v` already collects that exact file — there is no pytest.ini,
|
||||
# `pytest tests/test_utils_system.py`, and Backend CI's
|
||||
# `pytest tests -v` already collects that exact file — there is no pytest.ini,
|
||||
# addopts, or collect_ignore narrowing it. What is left here is the part
|
||||
# Backend CI genuinely cannot do: exercise the package-manager detection
|
||||
# against real apt/dnf inside real distro images.
|
||||
|
||||
@@ -128,6 +128,8 @@ def create_app(config_name=None):
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
from app.middleware.request_profiling import register_request_profiling
|
||||
register_request_profiling(app, db)
|
||||
migrate.init_app(app, db)
|
||||
jwt.init_app(app)
|
||||
# Storage backend comes from app.config's RATELIMIT_STORAGE_URI when set
|
||||
|
||||
+17
-74
@@ -4,7 +4,6 @@
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
@@ -21,7 +20,7 @@ from app.services.source_connection_service import SourceConnectionService
|
||||
from app.services.remote_docker_service import RemoteDockerService
|
||||
from app.services.container_registry_service import ContainerRegistryService
|
||||
from app.services.image_update_service import ImageUpdateService
|
||||
from app.services import container_status_service, application_lifecycle_service
|
||||
from app.services import container_status_service, application_lifecycle_service, repository_application_service
|
||||
from app.services.application_lifecycle_service import (
|
||||
_compose_target, _local_compose_file, _agent_result_failed,
|
||||
_agent_result_error, _assert_managed_app_path,
|
||||
@@ -854,92 +853,36 @@ def create_app_from_repository():
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
|
||||
deploy_result = GitService.configure_deployment(
|
||||
app_id=app.id,
|
||||
app_path=app.root_path,
|
||||
repo_url=deploy_repo_url,
|
||||
branch=branch or 'main',
|
||||
auto_deploy=auto_deploy,
|
||||
created = repository_application_service.finalize_repository_application(
|
||||
app, user_id=user.id, repo_url=deploy_repo_url, branch=branch or 'main',
|
||||
auto_deploy=auto_deploy, manifest=manifest,
|
||||
build_options={
|
||||
'build_method': resolved_build_method,
|
||||
'dockerfile_path': dockerfile_path,
|
||||
'custom_build_cmd': custom_build_cmd,
|
||||
'custom_start_cmd': custom_start_cmd,
|
||||
'buildpack_plan': buildpack_plan,
|
||||
'buildpack_overrides': buildpack_overrides,
|
||||
},
|
||||
)
|
||||
if not deploy_result.get('success'):
|
||||
raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment'))
|
||||
|
||||
build_result = BuildService.configure_build(
|
||||
app_id=app.id,
|
||||
app_path=app.root_path,
|
||||
build_method=resolved_build_method,
|
||||
dockerfile_path=dockerfile_path,
|
||||
custom_build_cmd=custom_build_cmd,
|
||||
custom_start_cmd=custom_start_cmd,
|
||||
buildpack_plan=buildpack_plan,
|
||||
buildpack_overrides=buildpack_overrides,
|
||||
)
|
||||
if not build_result.get('success'):
|
||||
raise RuntimeError(build_result.get('error', 'Failed to configure build'))
|
||||
|
||||
# Stop dropping what we detect: persist the manifest, seed non-secret
|
||||
# env values, and record the health-check path (plan 17, Phase 1).
|
||||
manifest_summary = None
|
||||
try:
|
||||
from app.services.manifest_persistence_service import ManifestPersistenceService
|
||||
manifest_summary = ManifestPersistenceService.apply_import(
|
||||
app, manifest, user_id=user.id,
|
||||
source_repo=deploy_repo_url, source_ref=branch or 'main',
|
||||
)
|
||||
except Exception:
|
||||
manifest_summary = None
|
||||
|
||||
# Actually deploy + start the service: hand it to the same observable
|
||||
# DeploymentJob pipeline template installs use (kind 'app_deploy' →
|
||||
# unified job 'deploy.app' → DeploymentService.deploy). A queue failure
|
||||
# must NOT fail app creation — the service stays 'stopped' and the user
|
||||
# can deploy manually from the Builds tab.
|
||||
deploy_job_id = None
|
||||
try:
|
||||
from app.services.deployment_job_service import DeploymentJobService
|
||||
enqueue_result = DeploymentJobService.enqueue_app_deploy(
|
||||
app, user_id=user.id, trigger='install')
|
||||
if enqueue_result.get('success'):
|
||||
deploy_job_id = enqueue_result.get('job_id')
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
'app deploy enqueue failed for app %s: %s',
|
||||
app.id, enqueue_result.get('error'))
|
||||
except Exception as exc:
|
||||
current_app.logger.warning(
|
||||
'app deploy enqueue failed for app %s: %s', app.id, exc)
|
||||
|
||||
return jsonify({
|
||||
'message': 'Repository service created',
|
||||
'app': _attach_deploy_config(app.to_dict(include_linked=True)),
|
||||
'deploy_job_id': deploy_job_id,
|
||||
'manifest_import': manifest_summary,
|
||||
'deploy_job_id': created['deploy_job_id'],
|
||||
'manifest_import': created['manifest_import'],
|
||||
'deploy_config': {
|
||||
'repo_url': _safe_repo_url(deploy_repo_url),
|
||||
'branch': branch or 'main',
|
||||
'auto_deploy': auto_deploy,
|
||||
'webhook_url': deploy_result.get('webhook_url'),
|
||||
'webhook_url': created['deploy_result'].get('webhook_url'),
|
||||
},
|
||||
'build_config': build_result.get('config'),
|
||||
'build_config': created['build_config'],
|
||||
'detection': detection,
|
||||
'manifest': manifest,
|
||||
}), 201
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
if app.id:
|
||||
GitService.remove_deployment(app.id)
|
||||
# Unwinding a create that failed, not deleting an app someone made:
|
||||
# a hard delete (no query_active, no tombstone) is what belongs
|
||||
# here — a half-created service must not land in the recycle bin.
|
||||
existing_app = Application.query.get(app.id)
|
||||
if existing_app:
|
||||
db.session.delete(existing_app)
|
||||
db.session.commit()
|
||||
if os.path.abspath(app_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep):
|
||||
shutil.rmtree(app_path, ignore_errors=True)
|
||||
repository_application_service.abort_repository_creation(app)
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@@ -474,8 +474,9 @@ def cleanup_backups():
|
||||
@admin_required
|
||||
def list_schedules():
|
||||
"""List backup schedules."""
|
||||
from app.services.backup_schedule_service import server_timezone
|
||||
schedules = BackupService.get_schedules()
|
||||
return jsonify({'schedules': schedules}), 200
|
||||
return jsonify({'schedules': schedules, 'timezone': str(server_timezone())}), 200
|
||||
|
||||
|
||||
@backups_bp.route('/schedules', methods=['POST'])
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Opt-in request/SQL costs, without SQL text, parameters or persistent storage."""
|
||||
|
||||
from time import perf_counter_ns
|
||||
|
||||
from flask import g, has_request_context, request
|
||||
from sqlalchemy import event
|
||||
|
||||
|
||||
def register_request_profiling(app, database):
|
||||
"""Attach only when enabled; normal deployments install no query listeners.
|
||||
|
||||
Counts SQLAlchemy statements on the request thread, including failed SQL.
|
||||
Streaming body iteration and background jobs occur outside this interval.
|
||||
"""
|
||||
if not app.config.get('PROFILE_REQUESTS'):
|
||||
return
|
||||
if app.extensions.get('serverkit_request_profiling'):
|
||||
return
|
||||
app.extensions['serverkit_request_profiling'] = True
|
||||
|
||||
@app.before_request
|
||||
def begin_request():
|
||||
if request.path.startswith('/api/'):
|
||||
g.serverkit_profile = {
|
||||
'start': perf_counter_ns(), 'queries': 0, 'sql_ns': 0,
|
||||
}
|
||||
|
||||
def before_sql(_conn, _cursor, _statement, _parameters, context, _many):
|
||||
if not has_request_context():
|
||||
return
|
||||
state = getattr(g, 'serverkit_profile', None)
|
||||
if state is not None:
|
||||
state['queries'] += 1
|
||||
context._serverkit_profile = (state, perf_counter_ns())
|
||||
|
||||
def finish_sql(context):
|
||||
timing = getattr(context, '_serverkit_profile', None)
|
||||
if timing is not None:
|
||||
state, start = timing
|
||||
state['sql_ns'] += perf_counter_ns() - start
|
||||
context._serverkit_profile = None
|
||||
|
||||
def after_sql(_conn, _cursor, _statement, _parameters, context, _many):
|
||||
finish_sql(context)
|
||||
|
||||
def failed_sql(error_context):
|
||||
finish_sql(error_context.execution_context)
|
||||
|
||||
with app.app_context():
|
||||
for engine in set(database.engines.values()):
|
||||
event.listen(engine, 'before_cursor_execute', before_sql)
|
||||
event.listen(engine, 'after_cursor_execute', after_sql)
|
||||
event.listen(engine, 'handle_error', failed_sql)
|
||||
|
||||
@app.after_request
|
||||
def expose_profile(response):
|
||||
state = getattr(g, 'serverkit_profile', None)
|
||||
if state is not None:
|
||||
app_ms = (perf_counter_ns() - state['start']) / 1_000_000
|
||||
sql_ms = state['sql_ns'] / 1_000_000
|
||||
response.headers.add(
|
||||
'Server-Timing',
|
||||
f'app;dur={app_ms:.3f}, db;dur={sql_ms:.3f};'
|
||||
f'desc="{state["queries"]} queries"',
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Server-local legacy backup schedules, shared by the API and job tick.
|
||||
|
||||
Wall-clock schedules retain their original behavior: a missing spring-forward
|
||||
minute is skipped; both occurrences of a repeated autumn minute can run.
|
||||
All comparisons use UTC so Python's same-zone arithmetic cannot collapse folds.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
|
||||
from tzlocal import reload_localzone
|
||||
|
||||
WEEKDAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday',
|
||||
'saturday', 'sunday')
|
||||
|
||||
|
||||
def server_timezone():
|
||||
# A panel timezone change must take effect without restarting the process.
|
||||
return reload_localzone()
|
||||
|
||||
|
||||
def validate_schedule(schedule):
|
||||
value = schedule.get('schedule_time')
|
||||
if not isinstance(value, str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', value):
|
||||
raise ValueError('schedule_time must be a valid HH:MM time')
|
||||
days = schedule.get('days', ['daily'])
|
||||
if (not isinstance(days, list) or not days
|
||||
or any(not isinstance(day, str) or day not in (*WEEKDAYS, 'daily') for day in days)):
|
||||
raise ValueError('days must contain daily or valid lowercase weekday names')
|
||||
return tuple(map(int, value.split(':'))), days
|
||||
|
||||
|
||||
def _last_run(schedule, zone):
|
||||
try:
|
||||
last = datetime.fromisoformat(schedule.get('last_run') or '')
|
||||
# Existing records have naive server-local timestamps. New writes carry
|
||||
# an offset; retain compatibility without changing persisted schedules.
|
||||
return (last if last.tzinfo else last.replace(tzinfo=zone)).astimezone(timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def next_run(schedule, *, now=None, zone=None):
|
||||
"""Return the next eligible minute (including the current minute), or None.
|
||||
|
||||
Uses the same 120-second duplicate suppression as the original job tick.
|
||||
There is no missed-run catchup: only this minute or a future minute qualifies.
|
||||
Invalid legacy schedules remain readable and inert rather than breaking ticks.
|
||||
"""
|
||||
try:
|
||||
(hour, minute), days = validate_schedule(schedule)
|
||||
except ValueError:
|
||||
return None
|
||||
if not schedule.get('enabled', False):
|
||||
return None
|
||||
zone = zone or server_timezone()
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
raise ValueError('now must include a timezone')
|
||||
local_now = now.astimezone(zone)
|
||||
start = local_now.replace(second=0, microsecond=0).astimezone(timezone.utc)
|
||||
last = _last_run(schedule, zone)
|
||||
# A weekly occurrence can be absent during a DST jump; search through the
|
||||
# following week's occurrence as well.
|
||||
for offset in range(15):
|
||||
date = local_now.date() + timedelta(days=offset)
|
||||
if 'daily' not in days and WEEKDAYS[date.weekday()] not in days:
|
||||
continue
|
||||
wall = datetime(date.year, date.month, date.day, hour, minute)
|
||||
candidates = set()
|
||||
for fold in (0, 1):
|
||||
candidate = wall.replace(tzinfo=zone, fold=fold).astimezone(timezone.utc)
|
||||
# A nonexistent local minute round-trips to a different wall time.
|
||||
if candidate.astimezone(zone).replace(tzinfo=None) == wall:
|
||||
candidates.add(candidate)
|
||||
for candidate in sorted(candidates):
|
||||
if candidate < start:
|
||||
continue
|
||||
# For a due minute compare against the real tick time, preserving
|
||||
# dedup behavior for records stamped partway through that minute.
|
||||
check_time = max(candidate, now.astimezone(timezone.utc))
|
||||
if last is not None and (check_time - last).total_seconds() < 120:
|
||||
continue
|
||||
return candidate.astimezone(zone)
|
||||
return None
|
||||
|
||||
|
||||
def describe_schedule(schedule, *, globally_enabled=True, now=None, zone=None):
|
||||
zone = zone or server_timezone()
|
||||
result = dict(schedule)
|
||||
result['timezone'] = str(zone)
|
||||
try:
|
||||
validate_schedule(schedule)
|
||||
except ValueError as exc:
|
||||
result['schedule_error'] = str(exc)
|
||||
following = next_run(schedule, now=now, zone=zone) if globally_enabled else None
|
||||
result['next_run_at'] = following.isoformat() if following else None
|
||||
return result
|
||||
@@ -5,7 +5,7 @@ import shutil
|
||||
import tarfile
|
||||
import gzip
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from pathlib import Path
|
||||
import threading
|
||||
@@ -19,6 +19,7 @@ from app.utils.config_store import load_json_config, save_json_config
|
||||
from app.utils.formatting import format_bytes
|
||||
from app.utils.system import is_command_available, run_checked
|
||||
from app.services.telemetry_service import TelemetryService, generate_correlation_id
|
||||
from app.services import backup_schedule_service
|
||||
|
||||
# Unified job kind for asynchronous scheduled backups (see register_jobs).
|
||||
BACKUP_JOB_KIND = 'backup.run'
|
||||
@@ -660,18 +661,23 @@ class BackupService:
|
||||
'backup_type': backup_type,
|
||||
'target': target,
|
||||
'schedule_time': schedule_time,
|
||||
'days': days or ['daily'],
|
||||
'days': ['daily'] if days is None else days,
|
||||
'enabled': True,
|
||||
'upload_remote': upload_remote,
|
||||
'last_run': None,
|
||||
'last_status': None
|
||||
}
|
||||
|
||||
try:
|
||||
backup_schedule_service.validate_schedule(schedule_entry)
|
||||
except ValueError as exc:
|
||||
return {'success': False, 'error': str(exc)}
|
||||
config.setdefault('schedules', []).append(schedule_entry)
|
||||
result = cls.save_config(config)
|
||||
|
||||
if result.get('success'):
|
||||
return {'success': True, 'schedule': schedule_entry}
|
||||
return {'success': True, 'schedule': backup_schedule_service.describe_schedule(
|
||||
schedule_entry, globally_enabled=config.get('enabled', False))}
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@@ -684,9 +690,12 @@ class BackupService:
|
||||
if s.get('id') == schedule_id:
|
||||
allowed_fields = ['name', 'backup_type', 'target', 'schedule_time',
|
||||
'days', 'enabled', 'upload_remote']
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
schedules[i][field] = updates[field]
|
||||
updated = {**s, **{field: updates[field] for field in allowed_fields if field in updates}}
|
||||
try:
|
||||
backup_schedule_service.validate_schedule(updated)
|
||||
except ValueError as exc:
|
||||
return {'success': False, 'error': str(exc)}
|
||||
schedules[i] = updated
|
||||
config['schedules'] = schedules
|
||||
return cls.save_config(config)
|
||||
|
||||
@@ -710,7 +719,11 @@ class BackupService:
|
||||
def get_schedules(cls) -> List[Dict]:
|
||||
"""Get all backup schedules."""
|
||||
config = cls.get_config()
|
||||
return config.get('schedules', [])
|
||||
zone = backup_schedule_service.server_timezone()
|
||||
now = datetime.now(timezone.utc)
|
||||
return [backup_schedule_service.describe_schedule(
|
||||
entry, globally_enabled=config.get('enabled', False), now=now, zone=zone,
|
||||
) for entry in config.get('schedules', [])]
|
||||
|
||||
@classmethod
|
||||
def get_backup_stats(cls) -> Dict:
|
||||
@@ -895,27 +908,15 @@ class BackupService:
|
||||
if not config.get('enabled', False):
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
zone = backup_schedule_service.server_timezone()
|
||||
now = datetime.now(timezone.utc).astimezone(zone)
|
||||
current_time = now.strftime('%H:%M')
|
||||
current_day = now.strftime('%A').lower()
|
||||
dirty = False
|
||||
|
||||
for sched in config.get('schedules', []):
|
||||
if not sched.get('enabled', False):
|
||||
due = backup_schedule_service.next_run(sched, now=now, zone=zone)
|
||||
if due is None or due.astimezone(timezone.utc) > now.astimezone(timezone.utc):
|
||||
continue
|
||||
if sched.get('schedule_time') != current_time:
|
||||
continue
|
||||
days = sched.get('days', ['daily'])
|
||||
if 'daily' not in days and current_day not in days:
|
||||
continue
|
||||
# Skip if a run was enqueued / ran within the last ~2 minutes.
|
||||
last_run = sched.get('last_run')
|
||||
if last_run:
|
||||
try:
|
||||
if (now - datetime.fromisoformat(last_run)).total_seconds() < 120:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
JobService.enqueue(
|
||||
BACKUP_JOB_KIND,
|
||||
@@ -1018,7 +1019,7 @@ class BackupService:
|
||||
config = cls.get_config()
|
||||
for s in config.get('schedules', []):
|
||||
if s.get('id') == sched.get('id'):
|
||||
s['last_run'] = datetime.now().isoformat()
|
||||
s['last_run'] = datetime.now(timezone.utc).isoformat()
|
||||
s['last_status'] = 'success' if result and result.get('success') else 'failed'
|
||||
break
|
||||
cls.save_config(config)
|
||||
@@ -1037,7 +1038,7 @@ class BackupService:
|
||||
config = cls.get_config()
|
||||
for s in config.get('schedules', []):
|
||||
if s.get('id') == sched.get('id'):
|
||||
s['last_run'] = datetime.now().isoformat()
|
||||
s['last_run'] = datetime.now(timezone.utc).isoformat()
|
||||
s['last_status'] = 'failed'
|
||||
break
|
||||
cls.save_config(config)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Finalize repository imports with consistent compensation on setup failure.
|
||||
|
||||
The caller validates and authorizes the repository/workspace before constructing
|
||||
the application. Deployment configuration requires its committed ID. Mandatory
|
||||
configuration failure removes that half-created record and its managed source;
|
||||
optional manifest enrichment or queue failure keeps the registered application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from app import db, paths
|
||||
from app.models import Application
|
||||
from app.services.git_service import GitService
|
||||
from app.services.build_service import BuildService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def abort_repository_creation(app):
|
||||
"""Compensate a failed import, never placing half-created rows in the bin."""
|
||||
db.session.rollback()
|
||||
if app.id:
|
||||
GitService.remove_deployment(app.id)
|
||||
existing = Application.query.get(app.id)
|
||||
if existing:
|
||||
db.session.delete(existing)
|
||||
db.session.commit()
|
||||
if os.path.abspath(app.root_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep):
|
||||
shutil.rmtree(app.root_path, ignore_errors=True)
|
||||
|
||||
|
||||
def finalize_repository_application(app, *, user_id, repo_url, branch,
|
||||
auto_deploy, build_options, manifest):
|
||||
"""Commit and configure an authorized app, returning presentation data.
|
||||
|
||||
Compensation is exposed separately so an HTTP caller can also unwind a
|
||||
failure while building its response, retaining the existing route contract.
|
||||
Non-HTTP callers should use the same compensation if finalization raises.
|
||||
"""
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
deploy_result = GitService.configure_deployment(
|
||||
app_id=app.id, app_path=app.root_path, repo_url=repo_url,
|
||||
branch=branch, auto_deploy=auto_deploy,
|
||||
)
|
||||
if not deploy_result.get('success'):
|
||||
raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment'))
|
||||
build_result = BuildService.configure_build(
|
||||
app_id=app.id, app_path=app.root_path, **build_options,
|
||||
)
|
||||
if not build_result.get('success'):
|
||||
raise RuntimeError(build_result.get('error', 'Failed to configure build'))
|
||||
|
||||
manifest_summary = None
|
||||
try:
|
||||
from app.services.manifest_persistence_service import ManifestPersistenceService
|
||||
manifest_summary = ManifestPersistenceService.apply_import(
|
||||
app, manifest, user_id=user_id, source_repo=repo_url, source_ref=branch,
|
||||
)
|
||||
except Exception:
|
||||
# Enrichment has always been best-effort; creation remains usable.
|
||||
pass
|
||||
|
||||
deploy_job_id = None
|
||||
try:
|
||||
from app.services.deployment_job_service import DeploymentJobService
|
||||
result = DeploymentJobService.enqueue_app_deploy(app, user_id=user_id, trigger='install')
|
||||
if result.get('success'):
|
||||
deploy_job_id = result.get('job_id')
|
||||
else:
|
||||
logger.warning('app deploy enqueue failed for app %s: %s', app.id, result.get('error'))
|
||||
except Exception as exc:
|
||||
logger.warning('app deploy enqueue failed for app %s: %s', app.id, exc)
|
||||
|
||||
return {
|
||||
'deploy_result': deploy_result, 'build_config': build_result.get('config'),
|
||||
'manifest_import': manifest_summary, 'deploy_job_id': deploy_job_id,
|
||||
}
|
||||
@@ -66,6 +66,8 @@ class Config:
|
||||
# Database - use instance folder for Flask convention
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:////app/instance/serverkit.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
# Temporary local/staging profiling. Off by default; no SQL text is exposed.
|
||||
PROFILE_REQUESTS = _env_bool('SERVERKIT_PROFILE_REQUESTS', False)
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
|
||||
|
||||
@@ -1 +1 @@
|
||||
4791
|
||||
5089
|
||||
@@ -2,8 +2,8 @@
|
||||
"schema_version": 1,
|
||||
"policy": "Exact reviewed inventory of direct persistence, subprocess, and filesystem mutation calls in Flask API controllers. New calls are forbidden; remove entries as controllers delegate to services.",
|
||||
"counts": {
|
||||
"filesystem": 9,
|
||||
"persistence": 494
|
||||
"filesystem": 8,
|
||||
"persistence": 488
|
||||
},
|
||||
"owners": {
|
||||
"api/admin.py": {
|
||||
@@ -265,14 +265,7 @@
|
||||
"api/apps.py::create_app::persistence::db.session.commit()::1",
|
||||
"api/apps.py::create_app_db_snapshot::persistence::db.session.add(snapshot)::1",
|
||||
"api/apps.py::create_app_db_snapshot::persistence::db.session.commit()::1",
|
||||
"api/apps.py::create_app_from_repository::filesystem::shutil.rmtree(app_path, ignore_errors=True)::1",
|
||||
"api/apps.py::create_app_from_repository::persistence::Application.query.filter_by(name=name, server_id=None).first()::1",
|
||||
"api/apps.py::create_app_from_repository::persistence::Application.query.get(app.id)::1",
|
||||
"api/apps.py::create_app_from_repository::persistence::db.session.add(app)::1",
|
||||
"api/apps.py::create_app_from_repository::persistence::db.session.commit()::1",
|
||||
"api/apps.py::create_app_from_repository::persistence::db.session.commit()::2",
|
||||
"api/apps.py::create_app_from_repository::persistence::db.session.delete(existing_app)::1",
|
||||
"api/apps.py::create_app_from_repository::persistence::db.session.rollback()::1",
|
||||
"api/apps.py::create_manual_app::persistence::Application.query.filter_by(name=name).first()::1",
|
||||
"api/apps.py::create_manual_app::persistence::db.session.add(app)::1",
|
||||
"api/apps.py::create_manual_app::persistence::db.session.commit()::1",
|
||||
|
||||
@@ -39,11 +39,16 @@ def make_user(db, username=None, role='developer', password='x', **overrides):
|
||||
return user
|
||||
|
||||
|
||||
def headers_for(user):
|
||||
"""JWT auth headers for a User row (or a raw user id)."""
|
||||
def access_token_for(user, **token_options):
|
||||
"""Mint through one door, allowing explicit malformed/expired test claims."""
|
||||
from flask_jwt_extended import create_access_token
|
||||
user_id = getattr(user, 'id', user)
|
||||
return {'Authorization': f'Bearer {create_access_token(identity=user_id)}'}
|
||||
return create_access_token(identity=user_id, **token_options)
|
||||
|
||||
|
||||
def headers_for(user, **token_options):
|
||||
"""JWT auth headers for a User row (or a raw user id)."""
|
||||
return {'Authorization': f'Bearer {access_token_for(user, **token_options)}'}
|
||||
|
||||
|
||||
def make_workspace(db, name='ws', created_by=None, **overrides):
|
||||
|
||||
@@ -254,11 +254,12 @@ def test_cancelled_confirmation_does_not_create_pending_action(app):
|
||||
|
||||
|
||||
def test_stream_tool_rejects_logged_out_session_after_approval(app, no_pii):
|
||||
from flask_jwt_extended import create_access_token, decode_token
|
||||
from flask_jwt_extended import decode_token
|
||||
from factories import access_token_for
|
||||
from app.models import RevokedSession
|
||||
with app.app_context():
|
||||
user = make_user(db, role='admin')
|
||||
claims = decode_token(create_access_token(identity=user.id))
|
||||
claims = decode_token(access_token_for(user))
|
||||
called = []
|
||||
|
||||
def logout():
|
||||
|
||||
@@ -264,6 +264,26 @@ class TestFromRepositoryDeployWiring:
|
||||
assert res.status_code == 201
|
||||
assert res.get_json()['deploy_job_id'] is None
|
||||
|
||||
@pytest.mark.parametrize('stage', ['deployment', 'build'])
|
||||
def test_setup_failure_unwinds_committed_app(self, app, client, auth_headers, monkeypatch, stage):
|
||||
from unittest.mock import Mock
|
||||
from app.models import Application
|
||||
from app.services.git_service import GitService
|
||||
from app.services.build_service import BuildService
|
||||
self._mock_create_stack(monkeypatch, enqueue_result={'success': True, 'job_id': 'unused'})
|
||||
cleanup = Mock()
|
||||
monkeypatch.setattr(GitService, 'remove_deployment', cleanup)
|
||||
service, method = ((GitService, 'configure_deployment') if stage == 'deployment'
|
||||
else (BuildService, 'configure_build'))
|
||||
monkeypatch.setattr(service, method, Mock(return_value={'success': False, 'error': 'setup rejected'}))
|
||||
res = client.post('/api/v1/apps/from-repository', headers=auth_headers, json={
|
||||
'name': 'repo-failed', 'repo_url': 'https://github.com/acme/repo-failed.git',
|
||||
})
|
||||
assert res.status_code == 400
|
||||
assert res.get_json()['error'] == 'setup rejected'
|
||||
assert Application.query.filter_by(name='repo-failed').first() is None
|
||||
cleanup.assert_called_once()
|
||||
|
||||
|
||||
class TestListJobsAppIdFilter:
|
||||
def test_service_filter(self, app):
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Legacy schedule API and execution agree across zones and DST boundaries."""
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import backup_schedule_service as schedules
|
||||
from app.services.backup_service import BackupService
|
||||
|
||||
|
||||
def schedule(**overrides):
|
||||
return {'id': 'nightly', 'enabled': True, 'schedule_time': '02:30',
|
||||
'days': ['daily'], **overrides}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('zone_name,now,expected', [
|
||||
('America/New_York', '2026-09-05T03:00:00+00:00', '2026-09-05T02:30:00-04:00'),
|
||||
('Asia/Kolkata', '2026-09-05T00:00:00+00:00', '2026-09-06T02:30:00+05:30'),
|
||||
# 02:30 never occurs on the spring-forward date.
|
||||
('America/New_York', '2026-03-08T05:00:00+00:00', '2026-03-09T02:30:00-04:00'),
|
||||
])
|
||||
def test_next_run_uses_server_zone(zone_name, now, expected):
|
||||
result = schedules.next_run(schedule(), now=datetime.fromisoformat(now), zone=ZoneInfo(zone_name))
|
||||
assert result.isoformat() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('now', ['2026-03-08T05:00:00+00:00', '2026-03-01T09:00:00+00:00'])
|
||||
def test_weekly_spring_gap_skips_to_next_week(now):
|
||||
result = schedules.next_run(schedule(days=['sunday']),
|
||||
now=datetime.fromisoformat(now),
|
||||
zone=ZoneInfo('America/New_York'))
|
||||
assert result.isoformat() == '2026-03-15T02:30:00-04:00'
|
||||
|
||||
|
||||
def test_fall_back_retains_both_legacy_wall_clock_runs_without_double_enqueue():
|
||||
zone = ZoneInfo('America/New_York')
|
||||
entry = schedule(schedule_time='01:30')
|
||||
first = datetime.fromisoformat('2026-11-01T05:30:10+00:00')
|
||||
assert schedules.next_run(entry, now=first, zone=zone).isoformat() == '2026-11-01T01:30:00-04:00'
|
||||
entry['last_run'] = first.isoformat()
|
||||
assert schedules.next_run(entry, now=first, zone=zone).isoformat() == '2026-11-01T01:30:00-05:00'
|
||||
second = datetime.fromisoformat('2026-11-01T06:30:10+00:00')
|
||||
assert schedules.next_run(entry, now=second, zone=zone).isoformat() == '2026-11-01T01:30:00-05:00'
|
||||
entry['last_run'] = second.isoformat()
|
||||
assert schedules.next_run(entry, now=second, zone=zone).isoformat() == '2026-11-02T01:30:00-05:00'
|
||||
|
||||
|
||||
def test_naive_legacy_last_run_is_server_local_and_suppresses_current_minute():
|
||||
result = schedules.next_run(schedule(last_run='2026-09-05T02:30:05'),
|
||||
now=datetime.fromisoformat('2026-09-05T06:30:10+00:00'),
|
||||
zone=ZoneInfo('America/New_York'))
|
||||
assert result.isoformat() == '2026-09-06T02:30:00-04:00'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('overrides', [
|
||||
{'schedule_time': '25:00'}, {'schedule_time': '2:30'}, {'schedule_time': None},
|
||||
{'days': []}, {'days': 'daily'}, {'days': ['MONDAY']}, {'days': [None]},
|
||||
])
|
||||
def test_invalid_legacy_entries_are_visible_but_never_due(overrides):
|
||||
entry = schedule(**overrides)
|
||||
result = schedules.describe_schedule(entry, zone=ZoneInfo('UTC'))
|
||||
assert result['next_run_at'] is None
|
||||
assert result['schedule_error']
|
||||
assert result['timezone'] == 'UTC'
|
||||
assert 'next_run_at' not in entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize('global_enabled,entry_enabled', [(False, True), (True, False)])
|
||||
def test_disabled_schedules_have_no_countdown(global_enabled, entry_enabled):
|
||||
result = schedules.describe_schedule(schedule(enabled=entry_enabled),
|
||||
globally_enabled=global_enabled, zone=ZoneInfo('UTC'))
|
||||
assert result['next_run_at'] is None
|
||||
|
||||
|
||||
def test_invalid_create_or_update_does_not_write_or_mutate_config(monkeypatch):
|
||||
config = {'enabled': True, 'schedules': [schedule()]}
|
||||
original = deepcopy(config)
|
||||
save = Mock()
|
||||
monkeypatch.setattr(BackupService, 'get_config', lambda: config)
|
||||
monkeypatch.setattr(BackupService, 'save_config', save)
|
||||
assert not BackupService.add_schedule('test', 'files', '/srv', '24:01')['success']
|
||||
assert not BackupService.add_schedule('test', 'files', '/srv', '02:30', days=[])['success']
|
||||
assert not BackupService.update_schedule('nightly', {'schedule_time': '99:99'})['success']
|
||||
assert config == original
|
||||
save.assert_not_called()
|
||||
|
||||
|
||||
def test_list_and_scheduler_use_same_due_instant_without_persisting_metadata(monkeypatch):
|
||||
from app.jobs.service import JobService
|
||||
from app.services import backup_service
|
||||
zone = ZoneInfo('America/New_York')
|
||||
now = datetime.fromisoformat('2026-09-05T06:30:10+00:00')
|
||||
clock = Mock()
|
||||
clock.now.return_value = now
|
||||
monkeypatch.setattr(backup_service, 'datetime', clock)
|
||||
monkeypatch.setattr(schedules, 'server_timezone', lambda: zone)
|
||||
config = {'enabled': True, 'schedules': [schedule()]}
|
||||
monkeypatch.setattr(BackupService, 'get_config', lambda: config)
|
||||
monkeypatch.setattr(BackupService, 'save_config', Mock())
|
||||
enqueue = Mock()
|
||||
monkeypatch.setattr(JobService, 'enqueue', enqueue)
|
||||
shown = BackupService.get_schedules()[0]
|
||||
assert shown['next_run_at'] == '2026-09-05T02:30:00-04:00'
|
||||
BackupService.check_backup_schedules()
|
||||
enqueue.assert_called_once()
|
||||
assert datetime.fromisoformat(config['schedules'][0]['last_run']).astimezone(timezone.utc) == now
|
||||
assert 'next_run_at' not in config['schedules'][0]
|
||||
BackupService.check_backup_schedules()
|
||||
enqueue.assert_called_once()
|
||||
assert BackupService.get_schedules()[0]['next_run_at'] == '2026-09-06T02:30:00-04:00'
|
||||
|
||||
|
||||
def test_empty_schedule_api_still_exposes_server_timezone(client, auth_headers, monkeypatch):
|
||||
monkeypatch.setattr(BackupService, 'get_config', lambda: {'enabled': True, 'schedules': []})
|
||||
monkeypatch.setattr(schedules, 'server_timezone', lambda: ZoneInfo('Asia/Kolkata'))
|
||||
response = client.get('/api/v1/backups/schedules', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {'schedules': [], 'timezone': 'Asia/Kolkata'}
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
|
||||
import app.sockets as sk
|
||||
from app import db
|
||||
from factories import make_user
|
||||
from factories import make_user, access_token_for
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -38,11 +38,11 @@ def _run(app, handler, data=None, sid='sid-1'):
|
||||
|
||||
def _authed(sid='sid-1', role='developer'):
|
||||
with sk._connected_clients_lock:
|
||||
from flask_jwt_extended import create_access_token, decode_token
|
||||
from flask_jwt_extended import decode_token
|
||||
user = make_user(db, role=role)
|
||||
sk.connected_clients[sid] = {
|
||||
'user_id': user.id, 'role': role,
|
||||
'claims': decode_token(create_access_token(identity=user.id)),
|
||||
'claims': decode_token(access_token_for(user)),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -88,9 +88,8 @@ class _FakeReq:
|
||||
|
||||
|
||||
class TestSubscribeDeployAuth:
|
||||
"""The socketio test client is unusable on this Flask/Werkzeug pairing
|
||||
(`ctx.session` became read-only), so exercise the handler's auth guard
|
||||
directly by faking the socket context (request.sid / emit / join_room)."""
|
||||
"""Exercise handler gates directly, with the same persisted user, session
|
||||
claims and resource checks as a socket authenticated at connect time."""
|
||||
|
||||
def _patch(self, monkeypatch, sid):
|
||||
import app.sockets as sk
|
||||
@@ -100,6 +99,11 @@ class TestSubscribeDeployAuth:
|
||||
monkeypatch.setattr(sk, 'request', _FakeReq(sid))
|
||||
return sk, emitted, joined
|
||||
|
||||
def _authenticate(self, sk, sid, auth_headers):
|
||||
from flask_jwt_extended import decode_token
|
||||
claims = decode_token(auth_headers['Authorization'].removeprefix('Bearer '))
|
||||
sk.connected_clients[sid] = {'user_id': int(claims['sub']), 'role': 'admin', 'claims': claims}
|
||||
|
||||
def test_unauthenticated_join_rejected(self, app, monkeypatch):
|
||||
sk, emitted, joined = self._patch(monkeypatch, 'sid-unauth')
|
||||
sk.connected_clients.pop('sid-unauth', None) # not authenticated
|
||||
@@ -107,19 +111,30 @@ class TestSubscribeDeployAuth:
|
||||
assert joined == [] # never joined a room
|
||||
assert any(a and a[0] == 'error' for a, k in emitted)
|
||||
|
||||
def test_authenticated_join_succeeds(self, app, monkeypatch):
|
||||
def test_authenticated_join_succeeds(self, app, auth_headers, monkeypatch):
|
||||
sk, emitted, joined = self._patch(monkeypatch, 'sid-auth')
|
||||
sk.connected_clients['sid-auth'] = {'user_id': 1, 'role': 'admin'}
|
||||
job = _make_job()
|
||||
self._authenticate(sk, 'sid-auth', auth_headers)
|
||||
try:
|
||||
sk.CHANNELS['deploy']['subscribe']({'job_id': 'job-xyz'})
|
||||
sk.CHANNELS['deploy']['subscribe']({'job_id': job.id})
|
||||
finally:
|
||||
sk.connected_clients.pop('sid-auth', None)
|
||||
assert 'deploy_job-xyz' in joined
|
||||
assert f'deploy_{job.id}' in joined
|
||||
assert any(a and a[0] == 'subscribed' for a, k in emitted)
|
||||
|
||||
def test_missing_job_id_rejected(self, app, monkeypatch):
|
||||
def test_authenticated_unknown_job_is_rejected(self, app, auth_headers, monkeypatch):
|
||||
sk, emitted, joined = self._patch(monkeypatch, 'sid-unknown-job')
|
||||
self._authenticate(sk, 'sid-unknown-job', auth_headers)
|
||||
try:
|
||||
sk.CHANNELS['deploy']['subscribe']({'job_id': 'no-such-job'})
|
||||
finally:
|
||||
sk.connected_clients.pop('sid-unknown-job', None)
|
||||
assert joined == []
|
||||
assert any(a and a[0] == 'error' for a, k in emitted)
|
||||
|
||||
def test_missing_job_id_rejected(self, app, auth_headers, monkeypatch):
|
||||
sk, emitted, joined = self._patch(monkeypatch, 'sid-auth2')
|
||||
sk.connected_clients['sid-auth2'] = {'user_id': 1, 'role': 'admin'}
|
||||
self._authenticate(sk, 'sid-auth2', auth_headers)
|
||||
try:
|
||||
sk.CHANNELS['deploy']['subscribe']({})
|
||||
finally:
|
||||
|
||||
@@ -116,8 +116,9 @@ def test_appless_job_detail_requester_or_admin(client, job_rbac):
|
||||
"""An app-less job belongs to its requester (or a panel admin)."""
|
||||
s = job_rbac.s
|
||||
url = f'/api/v1/deployment-jobs/{job_rbac.owner_job_id}'
|
||||
assert client.get(url, headers=s.owner).status_code == 200
|
||||
assert client.get(url, headers=s.admin).status_code == 200
|
||||
for headers in (s.owner, s.admin):
|
||||
response = client.get(url, headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
for persona in ('member', 'viewer', 'foreign'):
|
||||
assert client.get(url, headers=getattr(s, persona)).status_code == 403, persona
|
||||
|
||||
|
||||
@@ -664,7 +664,7 @@ class TestApi:
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
body = resp.get_json()
|
||||
assert body['success'] is True
|
||||
assert body['connection']['id'] == second.id
|
||||
|
||||
@@ -74,7 +74,9 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None,
|
||||
build = subprocess.run(
|
||||
['docker', 'build', '-f', str(repo_path / 'Dockerfile.serverkit'),
|
||||
'-t', tag, str(repo_path)],
|
||||
capture_output=True, text=True, timeout=600,
|
||||
# Docker emits UTF-8 build logs even when Windows uses a legacy locale.
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
timeout=600,
|
||||
)
|
||||
try:
|
||||
assert build.returncode == 0, (
|
||||
@@ -87,13 +89,15 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None,
|
||||
run = subprocess.run(
|
||||
['docker', 'run', '-d', '--rm', '--name', tag,
|
||||
'-p', f'127.0.0.1:0:{port}', tag],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
timeout=60,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
try:
|
||||
mapped = subprocess.run(
|
||||
['docker', 'port', tag, str(port)],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
timeout=20,
|
||||
).stdout.strip().splitlines()[0] # e.g. 127.0.0.1:49321
|
||||
url = f'http://{mapped}{probe_path}'
|
||||
|
||||
@@ -127,7 +131,8 @@ def _deploy_and_probe(repo_path, expect_language, expect_framework=None,
|
||||
if body is None:
|
||||
logs = subprocess.run(
|
||||
['docker', 'logs', tag],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace',
|
||||
timeout=20,
|
||||
)
|
||||
raise AssertionError(
|
||||
f'container never answered {url}; logs:\n'
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Repository registration ordering and compensating cleanup, without HTTP."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import repository_application_service as imports
|
||||
from app.services.deployment_job_service import DeploymentJobService
|
||||
from app.services.manifest_persistence_service import ManifestPersistenceService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime(monkeypatch, tmp_path):
|
||||
app = SimpleNamespace(id=42, root_path=str(tmp_path / 'apps' / 'demo'))
|
||||
session = Mock()
|
||||
git = Mock()
|
||||
build = Mock()
|
||||
git.configure_deployment.return_value = {'success': True, 'webhook_url': '/hook'}
|
||||
build.configure_build.return_value = {'success': True, 'config': {'build_method': 'dockerfile'}}
|
||||
manifest = Mock(return_value={'imported': True})
|
||||
enqueue = Mock(return_value={'success': True, 'job_id': 'job-42'})
|
||||
monkeypatch.setattr(imports, 'db', SimpleNamespace(session=session))
|
||||
monkeypatch.setattr(imports, 'GitService', git)
|
||||
monkeypatch.setattr(imports, 'BuildService', build)
|
||||
monkeypatch.setattr(imports.paths, 'APPS_DIR', str(tmp_path / 'apps'))
|
||||
monkeypatch.setattr(ManifestPersistenceService, 'apply_import', manifest)
|
||||
monkeypatch.setattr(DeploymentJobService, 'enqueue_app_deploy', enqueue)
|
||||
options = dict(user_id=7, repo_url='https://github.com/acme/demo', branch='release',
|
||||
auto_deploy=False, manifest={'strategy': 'dockerfile'},
|
||||
build_options={'build_method': 'dockerfile', 'custom_start_cmd': 'serve'})
|
||||
return SimpleNamespace(app=app, session=session, git=git, build=build,
|
||||
manifest=manifest, enqueue=enqueue, options=options)
|
||||
|
||||
|
||||
def test_commit_precedes_configuration_and_observable_deployment(runtime):
|
||||
effects = Mock()
|
||||
for name, method in [('commit', runtime.session.commit),
|
||||
('deploy', runtime.git.configure_deployment),
|
||||
('build', runtime.build.configure_build),
|
||||
('manifest', runtime.manifest), ('enqueue', runtime.enqueue)]:
|
||||
effects.attach_mock(method, name)
|
||||
result = imports.finalize_repository_application(runtime.app, **runtime.options)
|
||||
assert [call[0] for call in effects.mock_calls] == ['commit', 'deploy', 'build', 'manifest', 'enqueue']
|
||||
assert result['deploy_job_id'] == 'job-42'
|
||||
assert result['manifest_import'] == {'imported': True}
|
||||
runtime.git.configure_deployment.assert_called_once_with(
|
||||
app_id=42, app_path=runtime.app.root_path, repo_url=runtime.options['repo_url'],
|
||||
branch='release', auto_deploy=False)
|
||||
runtime.build.configure_build.assert_called_once_with(
|
||||
app_id=42, app_path=runtime.app.root_path, **runtime.options['build_options'])
|
||||
runtime.enqueue.assert_called_once_with(runtime.app, user_id=7, trigger='install')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stage', ['deploy', 'build'])
|
||||
def test_mandatory_setup_failure_propagates_for_compensation(runtime, stage):
|
||||
method = runtime.git.configure_deployment if stage == 'deploy' else runtime.build.configure_build
|
||||
method.return_value = {'success': False, 'error': 'setup failed'}
|
||||
with pytest.raises(RuntimeError, match='setup failed'):
|
||||
imports.finalize_repository_application(runtime.app, **runtime.options)
|
||||
runtime.manifest.assert_not_called()
|
||||
runtime.enqueue.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('queue_failure', [False, True])
|
||||
def test_optional_enrichment_and_queue_failure_keep_registered_app(runtime, queue_failure):
|
||||
runtime.manifest.side_effect = RuntimeError('unsupported manifest')
|
||||
if queue_failure:
|
||||
runtime.enqueue.side_effect = RuntimeError('queue unavailable')
|
||||
else:
|
||||
runtime.enqueue.return_value = {'success': False, 'error': 'queue unavailable'}
|
||||
result = imports.finalize_repository_application(runtime.app, **runtime.options)
|
||||
assert result['manifest_import'] is None
|
||||
assert result['deploy_job_id'] is None
|
||||
runtime.session.commit.assert_called_once()
|
||||
runtime.session.rollback.assert_not_called()
|
||||
runtime.git.remove_deployment.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('location', ['managed', 'sibling', 'base'])
|
||||
def test_compensation_hard_deletes_record_but_only_removes_managed_child_path(runtime, monkeypatch, location):
|
||||
from pathlib import Path
|
||||
base = Path(imports.paths.APPS_DIR)
|
||||
target = {'managed': base / 'demo', 'sibling': base.with_name('apps-other'), 'base': base}[location]
|
||||
target.mkdir(parents=True)
|
||||
marker = target / 'keep.txt'
|
||||
marker.write_text('source')
|
||||
runtime.app.root_path = str(target)
|
||||
query = Mock()
|
||||
query.get.return_value = runtime.app
|
||||
monkeypatch.setattr(imports, 'Application', SimpleNamespace(query=query))
|
||||
effects = Mock()
|
||||
effects.attach_mock(runtime.session.rollback, 'rollback')
|
||||
effects.attach_mock(runtime.git.remove_deployment, 'deployment')
|
||||
effects.attach_mock(runtime.session.delete, 'delete')
|
||||
effects.attach_mock(runtime.session.commit, 'commit')
|
||||
imports.abort_repository_creation(runtime.app)
|
||||
assert [call[0] for call in effects.mock_calls] == ['rollback', 'deployment', 'delete', 'commit']
|
||||
assert marker.exists() == (location != 'managed')
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Profiling is opt-in, request-local and never discloses SQL or parameters."""
|
||||
|
||||
import re
|
||||
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.middleware.request_profiling import register_request_profiling
|
||||
|
||||
|
||||
def profile_app(enabled):
|
||||
app = Flask(__name__)
|
||||
app.config.update(SQLALCHEMY_DATABASE_URI='sqlite://', PROFILE_REQUESTS=enabled)
|
||||
database = SQLAlchemy(app)
|
||||
register_request_profiling(app, database)
|
||||
register_request_profiling(app, database) # registration must be idempotent
|
||||
|
||||
@app.get('/api/v1/query')
|
||||
def query():
|
||||
database.session.execute(text('SELECT :secret'), {'secret': 'private-value'})
|
||||
database.session.execute(text('SELECT 2'))
|
||||
return 'ok', 200, {'Server-Timing': 'existing;dur=1'}
|
||||
|
||||
@app.get('/api/v1/empty')
|
||||
def empty():
|
||||
return 'ok'
|
||||
|
||||
@app.get('/api/v1/fail')
|
||||
def fail():
|
||||
try:
|
||||
database.session.execute(text('SELECT * FROM private_missing_table'))
|
||||
except SQLAlchemyError:
|
||||
return 'failed', 400
|
||||
raise AssertionError('Expected invalid SQL')
|
||||
|
||||
@app.get('/page')
|
||||
def page():
|
||||
database.session.execute(text('SELECT 1'))
|
||||
return 'page'
|
||||
|
||||
return app, database
|
||||
|
||||
|
||||
def test_default_off_installs_no_profile_and_preserves_headers():
|
||||
app, _database = profile_app(False)
|
||||
response = app.test_client().get('/api/v1/query')
|
||||
assert response.headers.getlist('Server-Timing') == ['existing;dur=1']
|
||||
assert 'serverkit_request_profiling' not in app.extensions
|
||||
|
||||
|
||||
def test_counts_queries_without_leaking_sql_and_isolates_requests():
|
||||
app, database = profile_app(True)
|
||||
client = app.test_client()
|
||||
# A job/startup query must not be attributed to the next HTTP request.
|
||||
with app.app_context():
|
||||
database.session.execute(text('SELECT 1'))
|
||||
response = client.get('/api/v1/query')
|
||||
headers = response.headers.getlist('Server-Timing')
|
||||
assert headers[0] == 'existing;dur=1'
|
||||
assert re.fullmatch(r'app;dur=\d+\.\d{3}, db;dur=\d+\.\d{3};desc="2 queries"', headers[1])
|
||||
assert 'private' not in str(headers)
|
||||
assert 'SELECT' not in str(headers)
|
||||
assert 'desc="0 queries"' in client.get('/api/v1/empty').headers['Server-Timing']
|
||||
assert 'Server-Timing' not in client.get('/page').headers
|
||||
|
||||
|
||||
def test_failed_sql_is_counted_and_does_not_leak_into_the_next_request():
|
||||
app, _database = profile_app(True)
|
||||
client = app.test_client()
|
||||
response = client.get('/api/v1/fail')
|
||||
assert response.status_code == 400
|
||||
assert 'desc="1 queries"' in response.headers['Server-Timing']
|
||||
assert 'private_missing_table' not in response.headers['Server-Timing']
|
||||
assert 'desc="0 queries"' in client.get('/api/v1/empty').headers['Server-Timing']
|
||||
@@ -437,8 +437,10 @@ def test_deleted_and_inactive_jwt_users_fail_closed_on_get(
|
||||
response = client.get(
|
||||
f'/api/v1/restore-points/{point.id}', headers=inactive_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()['error'] == 'Account is deactivated'
|
||||
# Session validation rejects inactive/deleted identities before the route's
|
||||
# resource policy runs. They no longer carry authenticated credentials.
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()['msg'] == 'Token has been revoked'
|
||||
|
||||
deleted = make_user(db_session, role='developer')
|
||||
deleted_headers = headers_for(deleted)
|
||||
@@ -447,8 +449,8 @@ def test_deleted_and_inactive_jwt_users_fail_closed_on_get(
|
||||
response = client.get(
|
||||
f'/api/v1/restore-points/{point.id}', headers=deleted_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()['error'] == 'Authenticated user not found'
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()['msg'] == 'Token has been revoked'
|
||||
|
||||
|
||||
def test_developer_api_key_reaches_policy_guarded_post(
|
||||
|
||||
@@ -117,7 +117,6 @@ APPLICATION_BASELINE = {
|
||||
'test_site_base_domains.py',
|
||||
'test_site_routing.py',
|
||||
'test_sites_https.py',
|
||||
'test_soft_delete_leaks.py',
|
||||
'test_ssl_acme_contact.py',
|
||||
'test_url_swap.py',
|
||||
'test_workspace_scope.py',
|
||||
|
||||
@@ -3,9 +3,9 @@ from datetime import timedelta
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
from flask_jwt_extended import create_access_token, decode_token
|
||||
from flask_jwt_extended import decode_token
|
||||
|
||||
from factories import make_user, headers_for
|
||||
from factories import make_user, headers_for, access_token_for
|
||||
from app.services.api_key_service import ApiKeyService
|
||||
|
||||
|
||||
@@ -144,11 +144,11 @@ def test_revoked_pending_mfa_cannot_finish_login(client, db_session, change):
|
||||
|
||||
def test_unexpired_legacy_or_expired_mfa_tokens_are_rejected(client, db_session):
|
||||
user = make_user(db_session, totp_enabled=True, totp_secret=pyotp.random_base32())
|
||||
legacy = create_access_token(identity=user.id, additional_claims={'auth_version': None})
|
||||
legacy = access_token_for(user, additional_claims={'auth_version': None})
|
||||
assert client.get('/api/v1/auth/me', headers=_headers(legacy)).status_code == 401
|
||||
for expiration in (False, timedelta(seconds=-1)):
|
||||
pending = create_access_token(identity=user.id, additional_claims={'2fa_pending': True},
|
||||
expires_delta=expiration)
|
||||
pending = access_token_for(user, additional_claims={'2fa_pending': True},
|
||||
expires_delta=expiration)
|
||||
response = client.post('/api/v1/auth/2fa/verify', json={
|
||||
'temp_token': pending, 'code': pyotp.TOTP(user.totp_secret).now(),
|
||||
})
|
||||
|
||||
@@ -165,9 +165,9 @@ def test_detach_requires_group_and_target_write(client, sr_rbac):
|
||||
def test_create_group_requires_scope_write(client, sr_rbac):
|
||||
s = sr_rbac.s
|
||||
body = {'scope_type': 'workspace', 'scope_id': str(s.ws_id), 'name': 'New'}
|
||||
assert client.post(G, json=body, headers=s.member).status_code == 201
|
||||
assert client.post(G, json=body, headers=s.viewer).status_code == 403
|
||||
assert client.post(G, json=body, headers=s.foreign).status_code == 403
|
||||
for persona, status in [('member', 201), ('viewer', 403), ('foreign', 403)]:
|
||||
response = client.post(G, json=body, headers=getattr(s, persona))
|
||||
assert response.status_code == status, (persona, response.get_json())
|
||||
|
||||
|
||||
def test_create_group_application_scope_foreign_denied(client, sr_rbac):
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
from flask import g
|
||||
from flask_jwt_extended import create_access_token, verify_jwt_in_request
|
||||
from flask_jwt_extended import verify_jwt_in_request
|
||||
|
||||
from app.services.cf_ops_change_service import CfOpsChangeService
|
||||
from app.services.connect_format import iso_datetime
|
||||
from app.services.resource_tier_service import ResourceTierService
|
||||
from app.services.shared_resource_service import _current_user_id
|
||||
from app.utils.actor import current_actor_id
|
||||
from factories import make_user
|
||||
from factories import make_user, headers_for
|
||||
|
||||
|
||||
@pytest.mark.parametrize('lookup', [current_actor_id, _current_user_id, CfOpsChangeService._current_user_id])
|
||||
@@ -23,7 +23,7 @@ def test_shared_actor_preserves_authenticated_owner(app, db_session, kind):
|
||||
user = make_user(db_session, role='admin', username=f'actor-{kind}')
|
||||
headers = {}
|
||||
if kind == 'jwt':
|
||||
headers['Authorization'] = f'Bearer {create_access_token(identity=str(user.id))}'
|
||||
headers = headers_for(str(user.id))
|
||||
with app.test_request_context(headers=headers):
|
||||
if kind == 'jwt':
|
||||
verify_jwt_in_request()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Socket authorization must hold both when joining and during delivery."""
|
||||
import pytest
|
||||
from flask_jwt_extended import create_access_token, create_refresh_token
|
||||
from flask_jwt_extended import create_refresh_token
|
||||
|
||||
from app import sockets as sk
|
||||
from app.models.deployment_job import DeploymentJob
|
||||
from factories import make_user, make_application, headers_for
|
||||
from factories import make_user, make_application, headers_for, access_token_for
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -13,7 +13,7 @@ def clients(app):
|
||||
|
||||
def connect(user=None, token=None):
|
||||
client = sk.socketio.test_client(app, auth={
|
||||
'token': token or create_access_token(identity=user.id),
|
||||
'token': token or access_token_for(user),
|
||||
})
|
||||
opened.append(client)
|
||||
if client.is_connected():
|
||||
@@ -30,11 +30,11 @@ def clients(app):
|
||||
def test_socket_rejects_non_session_tokens(db_session, clients, kind):
|
||||
user = make_user(db_session, role='admin')
|
||||
if kind == 'pending':
|
||||
token = create_access_token(identity=user.id, additional_claims={'2fa_pending': True})
|
||||
token = access_token_for(user, additional_claims={'2fa_pending': True})
|
||||
elif kind == 'refresh':
|
||||
token = create_refresh_token(identity=user.id)
|
||||
else:
|
||||
token = create_access_token(identity=user.id, expires_delta=False)
|
||||
token = access_token_for(user, expires_delta=False)
|
||||
assert not clients(token=token).is_connected()
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ def test_run_polling_cannot_bypass_socket_gate(client, db_session):
|
||||
|
||||
def test_logout_revokes_existing_socket(client, db_session, clients):
|
||||
user = make_user(db_session)
|
||||
token = create_access_token(identity=user.id)
|
||||
token = access_token_for(user)
|
||||
sock = clients(token=token)
|
||||
response = client.post('/api/v1/auth/logout', headers={'Authorization': f'Bearer {token}'})
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -11,20 +11,20 @@ import pytest
|
||||
from app import db
|
||||
from app.models import Application, Domain
|
||||
from app.services.domain_attach_service import DomainAttachService
|
||||
from factories import make_application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deleted_domain(app):
|
||||
with app.app_context():
|
||||
application = Application(name='shop', app_type='docker', port=8001, user_id=1)
|
||||
db.session.add(application)
|
||||
db.session.flush()
|
||||
application = make_application(db, name='shop', port=8001)
|
||||
gone = Domain(name='old.example.com', application_id=application.id, is_primary=True)
|
||||
db.session.add(gone)
|
||||
db.session.commit()
|
||||
gone.soft_delete()
|
||||
db.session.commit()
|
||||
yield {'app_id': application.id, 'domain_id': gone.id, 'name': gone.name}
|
||||
yield {'app_id': application.id, 'user_id': application.user_id,
|
||||
'domain_id': gone.id, 'name': gone.name}
|
||||
|
||||
|
||||
def test_app_payload_hides_deleted_domains(app, deleted_domain):
|
||||
@@ -42,9 +42,8 @@ def test_a_deleted_name_is_not_a_clash(app, deleted_domain):
|
||||
"""Migration 083 made the unique index partial so deleting frees the name.
|
||||
An application-level clash check must not re-impose the burn."""
|
||||
with app.app_context():
|
||||
other = Application(name='other', app_type='docker', port=8002, user_id=1)
|
||||
db.session.add(other)
|
||||
db.session.commit()
|
||||
make_application(db, name='other', port=8002,
|
||||
user_id=deleted_domain['user_id'])
|
||||
|
||||
clash = Domain.query_active().filter_by(name=deleted_domain['name']).first()
|
||||
assert clash is None, 'a tombstone still blocks the name for another app'
|
||||
|
||||
@@ -796,6 +796,7 @@ Regenerate (backend/):
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/login-links`
|
||||
- `POST /auth/login-links/redeem`
|
||||
- `POST /auth/logout`
|
||||
- `POST /auth/passkeys/authenticate`
|
||||
- `POST /auth/passkeys/options/authenticate`
|
||||
- `POST /auth/passkeys/options/register`
|
||||
|
||||
@@ -18,11 +18,11 @@ debt. Regenerate with `python scripts/generate-migration-inventory.py`.
|
||||
| API crashes swallowed without recording | app.error_reporting | 0 | 0 | INVARIANT at 0 |
|
||||
| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1141 | 1150 | migrate when touched; new endpoints raise |
|
||||
| Raw subprocess calls outside the runners | app/utils/system.py runners | 24 | 24 | migrate when touched |
|
||||
| Controller-boundary violations (routes doing service work) | service layer extraction | 503 | 503 | migrate when touched (first-wave ratchet) |
|
||||
| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 402 | 405 | migrate when touched |
|
||||
| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 209 | 218 | migrate when touched |
|
||||
| hand-rolled form-group blocks | F2: FormField/useForm | 326 | 343 | migrate when touched |
|
||||
| unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 86 | 97 | migrate when touched |
|
||||
| Controller-boundary violations (routes doing service work) | service layer extraction | 496 | 496 | migrate when touched (first-wave ratchet) |
|
||||
| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 398 | 398 | migrate when touched |
|
||||
| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 206 | 206 | migrate when touched |
|
||||
| hand-rolled form-group blocks | F2: FormField/useForm | 321 | 321 | migrate when touched |
|
||||
| unencoded ?k=${v} query interpolations in services/api | C4: buildQuery/encoding template | 0 | 0 | INVARIANT at 0 |
|
||||
| raw setInterval pollers | E2: usePolling/refetchInterval | 7 | 7 | DELIBERATE RESIDUE: clock ticks, socket-fallback hooks, and sibling-repo extension timers - each listed per file |
|
||||
| direct navigator.clipboard call sites | F3: copyToClipboard | 0 | 0 | INVARIANT at 0 |
|
||||
| hex colour literals outside token files | G: var(--token) | 150 | 150 | migrate when touched |
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"lint:baseline": "node scripts/lint.mjs --update",
|
||||
"test:controls": "node scripts/controls-browser.mjs",
|
||||
"test:metrics": "node scripts/metrics-browser.mjs",
|
||||
"measure:build": "node scripts/measure-build.mjs",
|
||||
"test:hooks": "node scripts/hooks-browser.mjs",
|
||||
"test:backups": "node scripts/backups-browser.mjs",
|
||||
"lint:settings-index": "node scripts/check-settings-index.mjs",
|
||||
"lint:fonts": "node scripts/check-font-integrity.mjs",
|
||||
"lint:status": "node scripts/check-status-one-door.mjs",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
// Real query/mutation/form hooks and the production schedule modal. Only HTTP,
|
||||
// current workspace, toast output and translation are controlled boundaries.
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const fixture = `
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {WorkspaceTestContext} from '/src/contexts/useWorkspace.js';
|
||||
import {useBackupSchedules} from '/src/hooks/useBackupSchedules.js';
|
||||
import AddScheduleModal from '/src/components/backups/AddScheduleModal.jsx';
|
||||
window.calls=[];window.messages=[];window.workspace='one';
|
||||
window.toast={error:value=>window.messages.push(['error',value]),success:value=>window.messages.push(['success',value])};
|
||||
window.rows={one:[{id:1,name:'Daily',enabled:true,next_run_at:'2026-09-06T02:00:00-04:00',timezone:'America/New_York'}],two:[{id:2,name:'Other workspace',enabled:true}]};
|
||||
window.testApi={
|
||||
getBackupSchedules:()=>{window.calls.push(['get',window.workspace]);const result={schedules:window.rows[window.workspace].map(row=>({...row})),timezone:'America/New_York'};return window.holdRead?new Promise(resolve=>window.finishRead=()=>resolve(result)):Promise.resolve(result)},
|
||||
addBackupSchedule:(...args)=>{window.calls.push(['create',...args]);return new Promise((resolve,reject)=>{window.finishCreate=()=>{window.rows.one.push({id:3,name:args[0],enabled:true});resolve({id:3})};window.failCreate=()=>reject(Object.assign(new Error('Please fix the field'),{fieldErrors:{name:'Already exists'}}))})},
|
||||
updateBackupSchedule:(id,body)=>{window.calls.push(['toggle',id,body]);window.rows[window.workspace]=window.rows[window.workspace].map(row=>row.id===id?{...row,...body}:row);return Promise.resolve({})},
|
||||
removeBackupSchedule:id=>{window.calls.push(['remove',id]);window.rows[window.workspace]=window.rows[window.workspace].filter(row=>row.id!==id);return Promise.resolve({})}
|
||||
};
|
||||
function Screen(){const store=useBackupSchedules();const [open,setOpen]=useState(true);return <>
|
||||
<output id="rows">{JSON.stringify(store.schedules)}</output>
|
||||
<button id="toggle" onClick={()=>store.toggle(store.schedules[0])}>Toggle</button>
|
||||
<button id="remove" onClick={()=>store.remove(store.schedules[0].id)}>Remove</button>
|
||||
<button id="open" onClick={()=>setOpen(true)}>Create</button>
|
||||
<AddScheduleModal open={open} onClose={()=>setOpen(false)} onCreate={store.create} onCreated={()=>setOpen(false)} remoteEnabled timezone={store.timezone}/>
|
||||
</>}
|
||||
function App(){const [workspace,setWorkspace]=useState('one');window.changeWorkspace=()=>{window.workspace='two';setWorkspace('two')};return <WorkspaceTestContext.Provider value={{activeWorkspaceId:workspace}}><Screen/></WorkspaceTestContext.Provider>}
|
||||
createRoot(document.getElementById('root')).render(<React.StrictMode><App/></React.StrictMode>);
|
||||
`;
|
||||
const server = await createServer({
|
||||
root, configFile: false, logLevel: 'error', resolve: { alias: { '@': path.join(root, 'src') } },
|
||||
cacheDir: path.join(root, 'node_modules/.vite-backups-regression'),
|
||||
optimizeDeps: { entries: [] }, server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'backup-regression-fixtures', enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === 'react-i18next') return '\0test-i18n';
|
||||
if (id.endsWith('/backups-fixture.jsx')) return path.join(root, 'backups-fixture.jsx');
|
||||
},
|
||||
load(id) {
|
||||
const normalized = id.replaceAll('\\', '/');
|
||||
if (id === '\0test-i18n') return `const t=(key,fallback)=>typeof fallback==='string'?fallback:key;export const useTranslation=()=>({t});`;
|
||||
if (normalized.endsWith('/src/contexts/useWorkspace.js')) return `import {createContext,useContext} from 'react';export const WorkspaceTestContext=createContext({});export const useWorkspace=()=>useContext(WorkspaceTestContext);`;
|
||||
if (normalized.endsWith('/src/contexts/useToast.js')) return `export const useToast=()=>window.toast;`;
|
||||
if (normalized.endsWith('/src/services/api.js') || normalized.endsWith('/src/services/api/index.js')) return `export default new Proxy({}, {get:(_,key)=>(...args)=>window.testApi[key](...args)});`;
|
||||
if (normalized.endsWith('/backups-fixture.jsx')) return fixture;
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use('/backups-check', async (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(await vite.transformIndexHtml('/backups-check', '<div id="root"></div><script type="module" src="/backups-fixture.jsx"></script>'));
|
||||
});
|
||||
},
|
||||
}, react()],
|
||||
});
|
||||
const executablePath = [process.env.CHROME_PATH, chromium.executablePath(), 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find(candidate => candidate && existsSync(candidate));
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/backups-check`);
|
||||
await page.getByLabel('Schedule Name', { exact: false }).fill('Daily 2');
|
||||
await page.getByLabel('Application Name', { exact: false }).fill('my-app');
|
||||
assert.equal(await page.locator('.form-field__hint').textContent(), 'America/New_York');
|
||||
await page.evaluate(() => {
|
||||
const form = document.querySelector('[data-walkthrough="backup-schedule-form"]');
|
||||
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await page.waitForFunction(() => window.calls.filter(([type]) => type === 'create').length === 1);
|
||||
assert.equal(await page.locator('[data-walkthrough="backup-schedule-submit"]').isDisabled(), true);
|
||||
await page.evaluate(() => window.failCreate());
|
||||
await page.getByText('Already exists', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel('Schedule Name', { exact: false }).inputValue(), 'Daily 2');
|
||||
await page.getByLabel('Schedule Name', { exact: false }).fill('Unique schedule');
|
||||
await page.locator('[data-walkthrough="backup-schedule-submit"]').click();
|
||||
await page.waitForFunction(() => window.calls.filter(([type]) => type === 'create').length === 2);
|
||||
await page.evaluate(() => window.finishCreate());
|
||||
await page.locator('[data-walkthrough="backup-schedule-form"]').waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(() => document.querySelector('#rows').textContent.includes('Unique schedule'));
|
||||
console.log('PASS duplicate-submit guard, inline server errors, preserved input, retry and creation cache invalidation');
|
||||
|
||||
await page.locator('#toggle').click();
|
||||
await page.waitForFunction(() => JSON.parse(document.querySelector('#rows').textContent)[0].enabled === false);
|
||||
await page.locator('#remove').click();
|
||||
await page.waitForFunction(() => JSON.parse(document.querySelector('#rows').textContent).length === 1);
|
||||
assert.equal(await page.evaluate(() => window.messages.filter(([type]) => type === 'success').length), 2);
|
||||
await page.evaluate(() => { window.holdRead = true; window.changeWorkspace(); });
|
||||
await page.waitForFunction(() => Boolean(window.finishRead));
|
||||
assert.deepEqual(JSON.parse(await page.locator('#rows').textContent()), []);
|
||||
await page.evaluate(() => window.finishRead());
|
||||
await page.waitForFunction(() => document.querySelector('#rows').textContent.includes('Other workspace'));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS toggle/delete query invalidation and workspace isolation while a new workspace request is pending');
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -221,25 +221,25 @@ for (const file of LEGACY_POLLERS.keys()) {
|
||||
const ADOPTION_CEILINGS = [
|
||||
{
|
||||
name: 'raw api.* calls in pages/ (E1: useServerQuery/useServerMutation)',
|
||||
ceiling: 405,
|
||||
ceiling: 398,
|
||||
include: (file) => file.startsWith('pages/'),
|
||||
pattern: /\bapi\s*\.\s*\w+\s*\(/g,
|
||||
},
|
||||
{
|
||||
name: 'per-page toast.error extractions in pages/ (E1: query-layer error presentation)',
|
||||
ceiling: 218,
|
||||
ceiling: 206,
|
||||
include: (file) => file.startsWith('pages/'),
|
||||
pattern: /toast\s*\.\s*error\s*\(/g,
|
||||
},
|
||||
{
|
||||
name: 'hand-rolled form-group blocks (F2: FormField/useForm)',
|
||||
ceiling: 343,
|
||||
ceiling: 321,
|
||||
include: (file) => file.startsWith('pages/') || file.startsWith('components/'),
|
||||
pattern: /form-group/g,
|
||||
},
|
||||
{
|
||||
name: 'unencoded ?k=${v} query interpolations in services/api (C4: buildQuery/encoding template)',
|
||||
ceiling: 97,
|
||||
ceiling: 0,
|
||||
include: (file) => file.startsWith('services/api/'),
|
||||
pattern: /[?&][A-Za-z_]+=\$\{(?!encodeURIComponent)/g,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Deterministic byte census of a production dist. Gzip is per file, level 9;
|
||||
// totals are storage/transfer estimates, never a page-load speed measurement.
|
||||
import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, relative, dirname, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export function linkedAssets(html) {
|
||||
const result = new Set();
|
||||
for (const tag of html.matchAll(/<(?:script|link)\b[^>]*>/gi)) {
|
||||
const attributes = Object.fromEntries([...tag[0].matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)]
|
||||
.map((match) => [match[1].toLowerCase(), match[2]]));
|
||||
const asset = attributes.src || (['stylesheet', 'modulepreload'].includes(attributes.rel) ? attributes.href : null);
|
||||
if (asset && !/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(asset)) {
|
||||
result.add(decodeURIComponent(new URL(asset, 'https://build.invalid/').pathname).slice(1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function measureBuild(dist) {
|
||||
const root = resolve(dist);
|
||||
const walk = (directory) => readdirSync(directory).flatMap((name) => {
|
||||
const path = resolve(directory, name);
|
||||
return statSync(path).isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
const fingerprint = createHash('sha256');
|
||||
const files = walk(root).filter((path) => ['.js', '.mjs', '.css'].includes(extname(path))).sort().map((path) => {
|
||||
const bytes = readFileSync(path);
|
||||
const name = relative(root, path).replaceAll('\\', '/');
|
||||
fingerprint.update(name).update('\0').update(bytes).update('\0');
|
||||
return { path: name, bytes: bytes.length, gzip_bytes: gzipSync(bytes, { level: 9 }).length };
|
||||
});
|
||||
const linked = linkedAssets(readFileSync(resolve(root, 'index.html'), 'utf8'));
|
||||
const missing = [...linked].filter((path) => !files.some((file) => file.path === path));
|
||||
if (missing.length) throw new Error(`HTML-linked code assets missing from build: ${missing.join(', ')}`);
|
||||
const sum = (items) => ({
|
||||
files: items.length,
|
||||
bytes: items.reduce((total, file) => total + file.bytes, 0),
|
||||
gzip_bytes: items.reduce((total, file) => total + file.gzip_bytes, 0),
|
||||
});
|
||||
return {
|
||||
schema_version: 1,
|
||||
measured_at_utc: new Date().toISOString(),
|
||||
node_version: process.version,
|
||||
measured_code_sha256: fingerprint.digest('hex'),
|
||||
compression: 'gzip, level 9, each file separately; sizes are bytes',
|
||||
scope: 'Built JS/MJS/CSS, including public vendor shims. Excludes HTML, fonts, images, maps and other non-code assets.',
|
||||
html_linked_scope: 'Only script, stylesheet and modulepreload URLs in index.html; not a complete runtime network trace or every later lazy import.',
|
||||
all_code: sum(files),
|
||||
html_linked_code: sum(files.filter((file) => linked.has(file.path))),
|
||||
largest_code_assets: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 12),
|
||||
assets: files,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const args = process.argv.slice(2);
|
||||
const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback;
|
||||
const report = measureBuild(value('--dist', resolve(here, '../dist')));
|
||||
const output = value('--output', null);
|
||||
if (output) {
|
||||
mkdirSync(dirname(resolve(output)), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify({ all_code: report.all_code, html_linked_code: report.html_linked_code }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { linkedAssets, measureBuild } from './measure-build.mjs';
|
||||
|
||||
test('separates HTML-linked code from lazy chunks and excludes fonts/images', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'serverkit-measure-'));
|
||||
try {
|
||||
writeFileSync(join(directory, 'index.html'), '<script src="/app.js"></script><link rel="modulepreload" href="/shared.js"><link rel="stylesheet" href="/app.css"><link rel="icon" href="/icon.png">');
|
||||
for (const file of ['app.js', 'shared.js', 'lazy.js', 'app.css', 'font.woff2', 'icon.png']) {
|
||||
writeFileSync(join(directory, file), '12345');
|
||||
}
|
||||
const report = measureBuild(directory);
|
||||
assert.equal(report.all_code.files, 4);
|
||||
assert.equal(report.all_code.bytes, 20);
|
||||
assert.equal(report.html_linked_code.files, 3);
|
||||
assert.equal(report.html_linked_code.bytes, 15);
|
||||
assert.ok(report.all_code.gzip_bytes > report.html_linked_code.gzip_bytes);
|
||||
} finally { rmSync(directory, { recursive: true }); }
|
||||
});
|
||||
|
||||
test('deduplicates links, ignores external resources and detects missing assets', () => {
|
||||
assert.deepEqual([...linkedAssets('<script src="./app.js?v=2"></script><link rel="modulepreload" href="/app.js"><script src="https://example.test/external.js"></script>')], ['app.js']);
|
||||
const directory = mkdtempSync(join(tmpdir(), 'serverkit-measure-'));
|
||||
try {
|
||||
writeFileSync(join(directory, 'index.html'), '<script src="/missing.js"></script>');
|
||||
assert.throws(() => measureBuild(directory), /missing.js/);
|
||||
} finally { rmSync(directory, { recursive: true }); }
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from '@/components/Modal';
|
||||
import { FormField } from '@/components/FormField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import useForm from '@/hooks/useForm';
|
||||
|
||||
export default function AddScheduleModal({ open, onClose, onCreate, onCreated, remoteEnabled, timezone }) {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
initialValues: { name: '', backupType: 'application', target: '', scheduleTime: '02:00', days: ['daily'], uploadRemote: false },
|
||||
onSubmit: async (values) => {
|
||||
await onCreate(values);
|
||||
form.reset();
|
||||
onCreated();
|
||||
},
|
||||
});
|
||||
const targetLabel = form.values.backupType === 'files'
|
||||
? t('app.backups.schedulePaths', 'Paths (comma-separated)')
|
||||
: form.values.backupType === 'database'
|
||||
? t('app.backups.scheduleDatabase', 'Database (format: mysql:dbname or postgresql:dbname)')
|
||||
: t('app.backups.applicationName', 'Application Name');
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={() => { if (!form.isSubmitting) onClose(); }} title={t('app.backups.addBackupSchedule', 'Add Backup Schedule')}>
|
||||
<form onSubmit={form.handleSubmit} data-walkthrough="backup-schedule-form">
|
||||
{form.submitError && <p className="error-message" role="alert">{form.submitError}</p>}
|
||||
<FormField htmlFor="backup-schedule-name" label={t('app.backups.scheduleName', 'Schedule Name')} error={form.getFieldError('name')} required>
|
||||
<Input id="backup-schedule-name" type="text" {...form.getFieldProps('name')} placeholder={t('app.backups.dailyAppBackup', 'Daily App Backup')} required />
|
||||
</FormField>
|
||||
<FormField htmlFor="backup-schedule-type" label={t('app.backups.backupType', 'Backup Type')} error={form.getFieldError('backupType')}>
|
||||
<select id="backup-schedule-type" {...form.getFieldProps('backupType')}>
|
||||
<option value="application">{t('app.backups.application', 'Application')}</option>
|
||||
<option value="database">{t('app.backups.database', 'Database')}</option>
|
||||
<option value="files">{t('app.backups.filesDirectories', 'Files / Directories')}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField htmlFor="backup-schedule-target" label={targetLabel} error={form.getFieldError('target')} required>
|
||||
<Input id="backup-schedule-target" type="text" {...form.getFieldProps('target')} placeholder={form.values.backupType === 'files' ? '/etc/nginx,/var/www/config' : form.values.backupType === 'database' ? 'mysql:mydb' : 'my-app'} required />
|
||||
</FormField>
|
||||
<FormField htmlFor="backup-schedule-time" label={t('common.labels.time', 'Time')} error={form.getFieldError('scheduleTime')} hint={timezone} required>
|
||||
<Input id="backup-schedule-time" type="time" {...form.getFieldProps('scheduleTime')} required />
|
||||
</FormField>
|
||||
{remoteEnabled && (
|
||||
<FormField>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" name="uploadRemote" checked={form.values.uploadRemote} onChange={form.handleChange} />
|
||||
<span>{t('app.backups.uploadToRemoteStorageAfterBackup', 'Upload to remote storage after backup')}</span>
|
||||
</label>
|
||||
</FormField>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={form.isSubmitting}>{t('common.actions.cancel', 'Cancel')}</Button>
|
||||
<Button type="submit" disabled={form.isSubmitting} data-walkthrough="backup-schedule-submit">{t('app.backups.addSchedule', 'Add Schedule')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -249,7 +249,7 @@ export default function BackupsOverview({
|
||||
const nextScheduled = (() => {
|
||||
const upcoming = (schedules || [])
|
||||
.filter((s) => s.enabled !== false)
|
||||
.map((s) => ({ schedule: s, at: nextFire(s, now) }))
|
||||
.map((s) => ({ schedule: s, at: nextFire(s) }))
|
||||
.filter((x) => x.at)
|
||||
.sort((a, b) => a.at - b.at);
|
||||
return upcoming[0] || null;
|
||||
@@ -301,8 +301,8 @@ export default function BackupsOverview({
|
||||
<div className="sk-kpi__sub">
|
||||
<span>
|
||||
{nextScheduled
|
||||
? `${nextScheduled.schedule.name || 'schedule'} · ${nextScheduled.schedule.schedule_time}`
|
||||
: 'no enabled schedules'}
|
||||
? `${nextScheduled.schedule.name || 'schedule'} · ${[nextScheduled.schedule.schedule_time, nextScheduled.schedule.timezone].filter(Boolean).join(' ')}`
|
||||
: t('app.backupsOverview.noScheduledRun', 'no scheduled run reported')}
|
||||
</span>
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
@@ -108,14 +108,15 @@ export default function SchedulesTable({
|
||||
headerKey: 'app.schedulesTable.next', header: 'Next',
|
||||
sortable: true,
|
||||
sortValue: (s) => {
|
||||
const next = s.enabled ? nextFire(s, now) : null;
|
||||
const next = nextFire(s);
|
||||
return next ? next.getTime() : null;
|
||||
},
|
||||
cellClassName: 'sk-cell-mono',
|
||||
render: (schedule) => {
|
||||
const next = schedule.enabled ? nextFire(schedule, now) : null;
|
||||
const next = nextFire(schedule);
|
||||
if (schedule.schedule_error) return <span title={schedule.schedule_error}>{t('app.schedulesTable.invalidSchedule', 'Invalid schedule')}</span>;
|
||||
return schedule.enabled
|
||||
? (next ? untilLabel(next, now) : '—')
|
||||
? (next ? <span title={schedule.next_run_at}>{untilLabel(next, now)}</span> : '—')
|
||||
: <span className="bk-paused">paused</span>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
import { useServerMutation, useServerQuery } from './useServerQuery';
|
||||
|
||||
const SCHEDULE_KEY = ['backups', 'schedules'];
|
||||
const EMPTY_SCHEDULES = [];
|
||||
|
||||
// One workspace-scoped cache owns policy CRUD and scheduler timing. Refresh
|
||||
// through the shared poller so a passed next_run_at advances without reloading
|
||||
// the whole backup archive or overwriting unfinished settings forms.
|
||||
export function useBackupSchedules() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const query = useServerQuery(SCHEDULE_KEY, () => api.getBackupSchedules(), {
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const create = useServerMutation((values) => api.addBackupSchedule(
|
||||
values.name, values.backupType, values.target, values.scheduleTime,
|
||||
values.days, values.uploadRemote,
|
||||
), { invalidate: [SCHEDULE_KEY] });
|
||||
const toggle = useServerMutation((schedule) => api.updateBackupSchedule(schedule.id, { enabled: !schedule.enabled }), {
|
||||
invalidate: [SCHEDULE_KEY],
|
||||
onSuccess: (_, schedule) => toast.success(t('app.backups.schedule', 'Schedule {{value}}', { value: schedule.enabled ? 'disabled' : 'enabled' })),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const remove = useServerMutation((id) => api.removeBackupSchedule(id), {
|
||||
invalidate: [SCHEDULE_KEY],
|
||||
onSuccess: () => toast.success(t('app.backups.scheduleRemoved', 'Schedule removed')),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const refetch = query.refetch;
|
||||
const reportError = toast.error;
|
||||
const refresh = useCallback(() => refetch().catch((error) => reportError(error.message)), [refetch, reportError]);
|
||||
|
||||
// UI event handlers intentionally resolve after the mutation's shared error
|
||||
// presentation. Creation retains rejection for useForm's inline errors.
|
||||
return {
|
||||
schedules: query.data?.schedules ?? EMPTY_SCHEDULES,
|
||||
timezone: query.data?.timezone,
|
||||
isLoading: query.isLoading,
|
||||
refresh,
|
||||
create: create.mutate,
|
||||
toggle: (schedule) => toggle.mutate(schedule).catch(() => undefined),
|
||||
remove: (id) => remove.mutate(id).catch(() => undefined),
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export default function useForm({ initialValues = {}, validate, onSubmit }) {
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
const validateRef = useRef(validate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const submitInFlight = useRef(false);
|
||||
validateRef.current = validate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
@@ -49,34 +50,42 @@ export default function useForm({ initialValues = {}, validate, onSubmit }) {
|
||||
|
||||
const handleSubmit = useCallback(async (event) => {
|
||||
event?.preventDefault?.();
|
||||
const values = state.values;
|
||||
const errors = normalizeFieldErrors(
|
||||
validateRef.current ? await validateRef.current(values) : {},
|
||||
);
|
||||
const touched = touchedFieldsFor(values);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
dispatch({ type: 'validationFailed', errors, touched });
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
dispatch({ type: 'submitStarted' });
|
||||
// A second submit event can arrive before React commits disabled buttons,
|
||||
// or while asynchronous validation is still running.
|
||||
if (submitInFlight.current) return { ok: false, pending: true };
|
||||
submitInFlight.current = true;
|
||||
try {
|
||||
const result = await onSubmitRef.current?.(values);
|
||||
dispatch({ type: 'submitFinished' });
|
||||
return { ok: true, value: result };
|
||||
} catch (error) {
|
||||
const mapped = mapServerFormError(error);
|
||||
const failedTouched = Object.fromEntries(
|
||||
Object.keys(mapped.fieldErrors).map((name) => [name, true]),
|
||||
const values = state.values;
|
||||
const errors = normalizeFieldErrors(
|
||||
validateRef.current ? await validateRef.current(values) : {},
|
||||
);
|
||||
dispatch({
|
||||
type: 'submitFailed',
|
||||
errors: mapped.fieldErrors,
|
||||
touched: failedTouched,
|
||||
submitError: mapped.formError,
|
||||
});
|
||||
return { ok: false, error, errors: mapped.fieldErrors };
|
||||
const touched = touchedFieldsFor(values);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
dispatch({ type: 'validationFailed', errors, touched });
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
dispatch({ type: 'submitStarted' });
|
||||
try {
|
||||
const result = await onSubmitRef.current?.(values);
|
||||
dispatch({ type: 'submitFinished' });
|
||||
return { ok: true, value: result };
|
||||
} catch (error) {
|
||||
const mapped = mapServerFormError(error);
|
||||
const failedTouched = Object.fromEntries(
|
||||
Object.keys(mapped.fieldErrors).map((name) => [name, true]),
|
||||
);
|
||||
dispatch({
|
||||
type: 'submitFailed',
|
||||
errors: mapped.fieldErrors,
|
||||
touched: failedTouched,
|
||||
submitError: mapped.formError,
|
||||
});
|
||||
return { ok: false, error, errors: mapped.fieldErrors };
|
||||
}
|
||||
} finally {
|
||||
submitInFlight.current = false;
|
||||
}
|
||||
}, [state.values]);
|
||||
|
||||
|
||||
@@ -43,17 +43,4 @@ export function useRecipeCatalog() {
|
||||
};
|
||||
}
|
||||
|
||||
// Start one recipe run. Returns the deployment job id; errors are toasted by
|
||||
// the mutation wrapper so callers can just navigate on success.
|
||||
export async function runRecipe({ startRun, toast, t }, body, { serverName }) {
|
||||
try {
|
||||
const result = await startRun.mutateAsync(body);
|
||||
toast.success(t('app.recipes.started', 'Recipe started on {{server}}.', {
|
||||
server: serverName,
|
||||
}));
|
||||
return result.job_id;
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('app.recipes.startFailed', 'Could not start the recipe'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export { runRecipe } from '../utils/runRecipe.js';
|
||||
@@ -388,6 +388,7 @@
|
||||
"applicationBackupCreated": "Application backup created",
|
||||
"applicationKey": "Application Key",
|
||||
"applicationKeyId": "Application Key ID",
|
||||
"applicationName": "Application Name",
|
||||
"areYouSureYouWantTo": "Are you sure you want to delete this backup?",
|
||||
"areYouSureYouWantTo3": "Are you sure you want to restore this backup? This may overwrite existing data.",
|
||||
"areYouSureYouWantTo5": "Are you sure you want to remove this schedule?",
|
||||
@@ -458,7 +459,9 @@
|
||||
"saveStorageConfig": "Save Storage Config",
|
||||
"schedule": "Schedule {{value}}",
|
||||
"scheduleAdded": "Schedule added",
|
||||
"scheduleDatabase": "Database (format: mysql:dbname or postgresql:dbname)",
|
||||
"scheduleName": "Schedule Name",
|
||||
"schedulePaths": "Paths (comma-separated)",
|
||||
"scheduleRemoved": "Schedule removed",
|
||||
"searchSnapshots": "Search snapshots…",
|
||||
"secretKey": "Secret Key",
|
||||
@@ -490,6 +493,7 @@
|
||||
"latestSnapshots": "Latest snapshots",
|
||||
"manage": "Manage",
|
||||
"nextScheduled": "Next scheduled",
|
||||
"noScheduledRun": "no scheduled run reported",
|
||||
"noSnapshotsYet": "No snapshots yet",
|
||||
"nothingHasRunYetTheFirst": "Nothing has run yet — the first backup will show up here.",
|
||||
"protectedResources": "Protected resources",
|
||||
@@ -4319,6 +4323,7 @@
|
||||
"disable": "Disable {{name}}",
|
||||
"enable": "Enable {{name}}",
|
||||
"frequency": "Frequency",
|
||||
"invalidSchedule": "Invalid schedule",
|
||||
"lastRun": "Last run",
|
||||
"next": "Next",
|
||||
"policy": "Policy",
|
||||
|
||||
+28
-150
@@ -21,6 +21,8 @@ import {
|
||||
} from '@/components/ds/grid';
|
||||
import BackupsOverview from '../components/backups/BackupsOverview';
|
||||
import SchedulesTable from '../components/backups/SchedulesTable';
|
||||
import AddScheduleModal from '../components/backups/AddScheduleModal';
|
||||
import { useBackupSchedules } from '../hooks/useBackupSchedules';
|
||||
import StorageDestinations from '../components/backups/StorageDestinations';
|
||||
import { useTopbarActions, useTopbarChrome } from '@/hooks/useTopbarActions';
|
||||
import { useTableSort } from '@/hooks/useTableSort';
|
||||
@@ -127,7 +129,8 @@ const Backups = () => {
|
||||
const { confirm } = useConfirm();
|
||||
const [backups, setBackups] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
const scheduleStore = useBackupSchedules();
|
||||
const { schedules, refresh: reloadSchedules } = scheduleStore;
|
||||
const [config, setConfig] = useState(null);
|
||||
const [storageConfig, setStorageConfig] = useState(null);
|
||||
const [costSummary, setCostSummary] = useState(null);
|
||||
@@ -169,16 +172,6 @@ const Backups = () => {
|
||||
fileName: ''
|
||||
});
|
||||
|
||||
// Schedule form state
|
||||
const [scheduleForm, setScheduleForm] = useState({
|
||||
name: '',
|
||||
backupType: 'application',
|
||||
target: '',
|
||||
scheduleTime: '02:00',
|
||||
days: ['daily'],
|
||||
uploadRemote: false
|
||||
});
|
||||
|
||||
// Config form state
|
||||
const [configForm, setConfigForm] = useState({
|
||||
enabled: false,
|
||||
@@ -198,17 +191,12 @@ const Backups = () => {
|
||||
keep_local_copy: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async ({ refreshSchedules = true } = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [backupsRes, statsRes, schedulesRes, configRes, appsRes, storageRes, costRes, ratesRes] = await Promise.all([
|
||||
const [backupsRes, statsRes, configRes, appsRes, storageRes, costRes, ratesRes] = await Promise.all([
|
||||
api.getBackups(),
|
||||
api.getBackupStats(),
|
||||
api.getBackupSchedules(),
|
||||
api.getBackupConfig(),
|
||||
api.getApps(),
|
||||
api.getStorageConfig().catch(() => null),
|
||||
@@ -218,8 +206,8 @@ const Backups = () => {
|
||||
|
||||
setBackups(backupsRes.backups || []);
|
||||
setStats(statsRes);
|
||||
setSchedules(schedulesRes.schedules || []);
|
||||
setConfig(configRes);
|
||||
if (refreshSchedules) await reloadSchedules();
|
||||
// GET /apps responds { apps: [...] } — reading `.applications` here
|
||||
// silently produced [] on every load, so the app picker in the
|
||||
// backup dialogs was permanently empty.
|
||||
@@ -250,7 +238,11 @@ const Backups = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [reloadSchedules]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData({ refreshSchedules: false });
|
||||
}, [loadData]);
|
||||
|
||||
const handleCreateBackup = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -342,49 +334,18 @@ const Backups = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSchedule = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.addBackupSchedule(
|
||||
scheduleForm.name,
|
||||
scheduleForm.backupType,
|
||||
scheduleForm.target,
|
||||
scheduleForm.scheduleTime,
|
||||
scheduleForm.days,
|
||||
scheduleForm.uploadRemote
|
||||
);
|
||||
toast.success(t('app.backups.scheduleAdded', 'Schedule added'));
|
||||
window.dispatchEvent(new CustomEvent('serverkit:walkthrough-signal', {
|
||||
detail: { type: 'backup-schedule-created' },
|
||||
}));
|
||||
setShowScheduleModal(false);
|
||||
resetScheduleForm();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSchedule = async (schedule) => {
|
||||
try {
|
||||
await api.updateBackupSchedule(schedule.id, { enabled: !schedule.enabled });
|
||||
toast.success(t('app.backups.schedule', 'Schedule {{value}}', { value: schedule.enabled ? 'disabled' : 'enabled' }));
|
||||
loadData();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
const handleScheduleCreated = () => {
|
||||
toast.success(t('app.backups.scheduleAdded', 'Schedule added'));
|
||||
window.dispatchEvent(new CustomEvent('serverkit:walkthrough-signal', {
|
||||
detail: { type: 'backup-schedule-created' },
|
||||
}));
|
||||
setShowScheduleModal(false);
|
||||
};
|
||||
|
||||
const handleRemoveSchedule = async (scheduleId) => {
|
||||
const confirmed = await confirm({ title: t('app.backups.removeSchedule', 'Remove Schedule'), message: t('app.backups.areYouSureYouWantTo5', 'Are you sure you want to remove this schedule?') });
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.removeBackupSchedule(scheduleId);
|
||||
toast.success(t('app.backups.scheduleRemoved', 'Schedule removed'));
|
||||
loadData();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
await scheduleStore.remove(scheduleId);
|
||||
};
|
||||
|
||||
const handleSaveConfig = async (e) => {
|
||||
@@ -471,17 +432,6 @@ const Backups = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const resetScheduleForm = () => {
|
||||
setScheduleForm({
|
||||
name: '',
|
||||
backupType: 'application',
|
||||
target: '',
|
||||
scheduleTime: '02:00',
|
||||
days: ['daily'],
|
||||
uploadRemote: false
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
@@ -858,7 +808,7 @@ const Backups = () => {
|
||||
schedules={schedules}
|
||||
retentionDays={config?.retention_days || 30}
|
||||
remoteLabel={PROVIDER_LABELS[storageConfig?.provider] || 'Local disk'}
|
||||
onToggle={handleToggleSchedule}
|
||||
onToggle={scheduleStore.toggle}
|
||||
onRemove={handleRemoveSchedule}
|
||||
/>
|
||||
)}
|
||||
@@ -1315,86 +1265,14 @@ const Backups = () => {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Add Schedule Modal */}
|
||||
<Modal open={showScheduleModal} onClose={() => setShowScheduleModal(false)} title={t('app.backups.addBackupSchedule', 'Add Backup Schedule')}>
|
||||
<form onSubmit={handleAddSchedule} data-walkthrough="backup-schedule-form">
|
||||
<div className="form-group">
|
||||
<label>{t('app.backups.scheduleName', 'Schedule Name')}</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={scheduleForm.name}
|
||||
onChange={(e) => setScheduleForm({...scheduleForm, name: e.target.value})}
|
||||
placeholder={t('app.backups.dailyAppBackup', 'Daily App Backup')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('app.backups.backupType', 'Backup Type')}</label>
|
||||
<select
|
||||
value={scheduleForm.backupType}
|
||||
onChange={(e) => setScheduleForm({...scheduleForm, backupType: e.target.value})}
|
||||
>
|
||||
<option value="application">{t('app.backups.application', 'Application')}</option>
|
||||
<option value="database">{t('app.backups.database', 'Database')}</option>
|
||||
<option value="files">{t('app.backups.filesDirectories', 'Files / Directories')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{scheduleForm.backupType === 'files'
|
||||
? 'Paths (comma-separated)'
|
||||
: scheduleForm.backupType === 'database'
|
||||
? 'Database (format: mysql:dbname or postgresql:dbname)'
|
||||
: 'Application Name'
|
||||
}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={scheduleForm.target}
|
||||
onChange={(e) => setScheduleForm({...scheduleForm, target: e.target.value})}
|
||||
placeholder={
|
||||
scheduleForm.backupType === 'files'
|
||||
? '/etc/nginx,/var/www/config'
|
||||
: scheduleForm.backupType === 'database'
|
||||
? 'mysql:mydb'
|
||||
: 'my-app'
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('common.labels.time', 'Time')}</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={scheduleForm.scheduleTime}
|
||||
onChange={(e) => setScheduleForm({...scheduleForm, scheduleTime: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storageConfig?.provider !== 'local' && (
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scheduleForm.uploadRemote}
|
||||
onChange={(e) => setScheduleForm({...scheduleForm, uploadRemote: e.target.checked})}
|
||||
/>
|
||||
<span>{t('app.backups.uploadToRemoteStorageAfterBackup', 'Upload to remote storage after backup')}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<Button type="button" variant="outline" onClick={() => setShowScheduleModal(false)}>
|
||||
{t('common.actions.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" data-walkthrough="backup-schedule-submit">{t('app.backups.addSchedule', 'Add Schedule')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<AddScheduleModal
|
||||
open={showScheduleModal}
|
||||
onClose={() => setShowScheduleModal(false)}
|
||||
onCreate={scheduleStore.create}
|
||||
onCreated={handleScheduleCreated}
|
||||
remoteEnabled={Boolean(storageConfig?.provider && storageConfig.provider !== 'local')}
|
||||
timezone={scheduleStore.timezone || schedules[0]?.timezone}
|
||||
/>
|
||||
|
||||
{/* Restore Modal */}
|
||||
<Modal open={showRestoreModal && !!selectedBackup} onClose={() => setShowRestoreModal(false)} title={t('app.backups.restoreBackup', 'Restore Backup')}>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getInvitations } from '../auth.js';
|
||||
import { getProcesses, killProcess } from '../system.js';
|
||||
import { getSSHKeys } from '../security.js';
|
||||
import { compareServerMetrics, getFleetComparison, searchFleet, exportFleetCsv } from '../servers.js';
|
||||
import { suggestSubdomain } from '../files.js';
|
||||
import { getDeploymentJobLogs } from '../deploymentJobs.js';
|
||||
|
||||
const capture = { request: async (url, options) => ({ url: new URL(url, 'https://panel.test'), options }) };
|
||||
const special = 'a &admin=true+# café/東京?%';
|
||||
|
||||
test('user-entered query values cannot add parameters or URL fragments', async () => {
|
||||
for (const [method, args, key] of [
|
||||
[getInvitations, [special], 'status'],
|
||||
[getSSHKeys, [special], 'user'],
|
||||
[getProcesses, [25, special], 'sort'],
|
||||
[searchFleet, [special, special], 'type'],
|
||||
]) {
|
||||
const { url } = await method.call(capture, ...args);
|
||||
assert.equal(url.searchParams.get(key), special);
|
||||
assert.equal(url.searchParams.has('admin'), false);
|
||||
assert.equal(url.hash, '');
|
||||
}
|
||||
const { url } = await searchFleet.call(capture, special, special);
|
||||
assert.equal(url.searchParams.get('q'), special); // Existing encoding is not doubled.
|
||||
});
|
||||
|
||||
test('numeric zero, false, optional omission and CSV array contracts stay intact', async () => {
|
||||
assert.equal((await getProcesses.call(capture, 0)).url.searchParams.get('limit'), '0');
|
||||
assert.equal((await killProcess.call(capture, 42, false)).url.searchParams.get('force'), 'false');
|
||||
assert.equal((await getInvitations.call(capture, '')).url.search, '');
|
||||
assert.equal((await getDeploymentJobLogs.call(capture, 42, null)).url.search, '');
|
||||
for (const method of [compareServerMetrics, getFleetComparison]) {
|
||||
const { url } = await method.call(capture, ['one&admin=1', 'two space'], 'cpu', '24h');
|
||||
assert.deepEqual(url.searchParams.getAll('ids'), ['one&admin=1,two space']);
|
||||
assert.equal(url.searchParams.has('admin'), false);
|
||||
}
|
||||
const { url } = await suggestSubdomain.call(capture, 0, special);
|
||||
assert.equal(url.searchParams.get('application_id'), '0');
|
||||
assert.equal(url.searchParams.get('base'), special);
|
||||
});
|
||||
|
||||
test('CSV download query encoding preserves authenticated download behavior', async () => {
|
||||
const original = globalThis.fetch;
|
||||
let received;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
received = { url: new URL(url), options };
|
||||
return { blob: async () => 'csv' };
|
||||
};
|
||||
try {
|
||||
const result = await exportFleetCsv.call({ baseUrl: 'https://panel.test/api', getToken: () => 'token' }, ['one', 'two'], special, '24h');
|
||||
assert.equal(result, 'csv');
|
||||
assert.equal(received.url.searchParams.get('metric'), special);
|
||||
assert.equal(received.url.searchParams.get('ids'), 'one,two');
|
||||
assert.equal(received.options.headers.Authorization, 'Bearer token');
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -268,7 +268,7 @@ export async function exportEnvFile(appId, includeSecrets = true) {
|
||||
}
|
||||
|
||||
export async function getEnvVarHistory(appId, limit = 50) {
|
||||
return this.request(`/apps/${appId}/env/history?limit=${limit}`);
|
||||
return this.request(`/apps/${appId}/env/history?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function clearEnvVars(appId) {
|
||||
@@ -285,7 +285,7 @@ export async function getComposeServices(appId) {
|
||||
|
||||
// Docker App Logs and Status
|
||||
export async function getDockerAppLogs(appId, lines = 100) {
|
||||
return this.request(`/apps/${appId}/logs?lines=${lines}`);
|
||||
return this.request(`/apps/${appId}/logs?lines=${encodeURIComponent(lines)}`);
|
||||
}
|
||||
|
||||
export async function getDockerAppStatus(appId) {
|
||||
@@ -492,7 +492,7 @@ export async function triggerBuild(appId, noCache = false) {
|
||||
}
|
||||
|
||||
export async function getBuildLogs(appId, limit = 20) {
|
||||
return this.request(`/builds/apps/${appId}/build-logs?limit=${limit}`);
|
||||
return this.request(`/builds/apps/${appId}/build-logs?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getBuildLogDetail(appId, timestamp) {
|
||||
@@ -511,11 +511,11 @@ export async function deployApp(appId, options = {}) {
|
||||
}
|
||||
|
||||
export async function getDeployments(appId, limit = 20, offset = 0) {
|
||||
return this.request(`/builds/apps/${appId}/deployments?limit=${limit}&offset=${offset}`);
|
||||
return this.request(`/builds/apps/${appId}/deployments?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`);
|
||||
}
|
||||
|
||||
export async function getDeploymentDetail(deploymentId, includeLogs = false) {
|
||||
return this.request(`/builds/deployments/${deploymentId}?include_logs=${includeLogs}`);
|
||||
return this.request(`/builds/deployments/${deploymentId}?include_logs=${encodeURIComponent(includeLogs)}`);
|
||||
}
|
||||
|
||||
export async function getDeploymentDiff(deploymentId) {
|
||||
|
||||
@@ -162,7 +162,7 @@ export async function getPermissionTemplates() {
|
||||
|
||||
// Admin - Invitations endpoints
|
||||
export async function getInvitations(status) {
|
||||
const query = status ? `?status=${status}` : '';
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return this.request(`/admin/invitations/${query}`);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ export async function testEventSubscription(id) {
|
||||
}
|
||||
|
||||
export async function getEventDeliveries(id, page = 1) {
|
||||
return this.request(`/event-subscriptions/${id}/deliveries?page=${page}`);
|
||||
return this.request(`/event-subscriptions/${id}/deliveries?page=${encodeURIComponent(page)}`);
|
||||
}
|
||||
|
||||
export async function retryEventDelivery(subId, deliveryId) {
|
||||
@@ -371,21 +371,21 @@ export async function retryEventDelivery(subId, deliveryId) {
|
||||
|
||||
// API Analytics
|
||||
export async function getApiAnalyticsOverview(period = '24h') {
|
||||
return this.request(`/api-analytics/overview?period=${period}`);
|
||||
return this.request(`/api-analytics/overview?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getApiAnalyticsEndpoints(period = '24h', limit = 20) {
|
||||
return this.request(`/api-analytics/endpoints?period=${period}&limit=${limit}`);
|
||||
return this.request(`/api-analytics/endpoints?period=${encodeURIComponent(period)}&limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getApiAnalyticsErrors(period = '24h') {
|
||||
return this.request(`/api-analytics/errors?period=${period}`);
|
||||
return this.request(`/api-analytics/errors?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getApiAnalyticsTimeseries(period = '24h', interval = 'hour') {
|
||||
return this.request(`/api-analytics/timeseries?period=${period}&interval=${interval}`);
|
||||
return this.request(`/api-analytics/timeseries?period=${encodeURIComponent(period)}&interval=${encodeURIComponent(interval)}`);
|
||||
}
|
||||
|
||||
export async function getApiKeyUsage(keyId, period = '24h') {
|
||||
return this.request(`/api-analytics/keys/${keyId}/usage?period=${period}`);
|
||||
return this.request(`/api-analytics/keys/${keyId}/usage?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export async function getBandwidthApps() {
|
||||
}
|
||||
|
||||
export async function getAppBandwidth(appId, days = 90) {
|
||||
return this.request(`/bandwidth/apps/${appId}?days=${days}`);
|
||||
return this.request(`/bandwidth/apps/${appId}?days=${encodeURIComponent(days)}`);
|
||||
}
|
||||
|
||||
export async function runBandwidthAggregate(day = null) {
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function applyWaf(appId) {
|
||||
}
|
||||
|
||||
export async function getWafEvents(appId, limit = 50) {
|
||||
return this.request(`/waf/applications/${appId}/events?limit=${limit}`);
|
||||
return this.request(`/waf/applications/${appId}/events?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getWafStatus() {
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function grantPostgreSQLPrivileges(username, database, privileges =
|
||||
|
||||
// Backups
|
||||
export async function getDatabaseBackups(type = null) {
|
||||
const params = type ? `?type=${type}` : '';
|
||||
const params = type ? `?type=${encodeURIComponent(type)}` : '';
|
||||
return this.request(`/databases/backups${params}`);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export async function deleteDatabaseBackup(filename) {
|
||||
}
|
||||
|
||||
export async function generateDatabasePassword(length = 16) {
|
||||
return this.request(`/databases/generate-password?length=${length}`);
|
||||
return this.request(`/databases/generate-password?length=${encodeURIComponent(length)}`);
|
||||
}
|
||||
|
||||
// Query Execution
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getDeploymentJob(jobId, includeLogs = true, includePlan =
|
||||
}
|
||||
|
||||
export async function getDeploymentJobLogs(jobId, afterId = null) {
|
||||
const suffix = afterId ? `?after_id=${afterId}` : '';
|
||||
const suffix = afterId ? `?after_id=${encodeURIComponent(afterId)}` : '';
|
||||
return this.request(`/deployment-jobs/${jobId}/logs${suffix}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function applyDNSPreset(zoneId, preset, variables = {}) {
|
||||
}
|
||||
|
||||
export async function checkDNSPropagation(domain, type = 'A') {
|
||||
return this.request(`/dns/propagation/${domain}?type=${type}`);
|
||||
return this.request(`/dns/propagation/${domain}?type=${encodeURIComponent(type)}`);
|
||||
}
|
||||
|
||||
export async function getDomainRoutingDiagnostics(appId) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getDockerDiskUsage() {
|
||||
|
||||
// Containers
|
||||
export async function getContainers(all = true) {
|
||||
return this.request(`/docker/containers?all=${all}`);
|
||||
return this.request(`/docker/containers?all=${encodeURIComponent(all)}`);
|
||||
}
|
||||
|
||||
export async function getContainer(containerId) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function getFileTypeBreakdown(path = '/home', maxDepth = 3) {
|
||||
export async function downloadFile(path) {
|
||||
const token = this.getToken();
|
||||
const url = `${this.baseUrl}/files/download?path=${encodeURIComponent(path)}`;
|
||||
window.open(`${url}&token=${token}`, '_blank');
|
||||
window.open(`${url}&token=${encodeURIComponent(token)}`, '_blank');
|
||||
}
|
||||
|
||||
export async function getFileBlob(path) {
|
||||
@@ -201,7 +201,7 @@ export async function controlFTPService(action, service = null) {
|
||||
}
|
||||
|
||||
export async function getFTPConfig(service = null) {
|
||||
const params = service ? `?service=${service}` : '';
|
||||
const params = service ? `?service=${encodeURIComponent(service)}` : '';
|
||||
return this.request(`/ftp/config${params}`);
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ export async function disconnectFTPSession(pid) {
|
||||
}
|
||||
|
||||
export async function getFTPLogs(lines = 100) {
|
||||
return this.request(`/ftp/logs?lines=${lines}`);
|
||||
return this.request(`/ftp/logs?lines=${encodeURIComponent(lines)}`);
|
||||
}
|
||||
|
||||
export async function installFTPServer(service = 'vsftpd') {
|
||||
@@ -311,7 +311,7 @@ export async function toggleWebhook(webhookId) {
|
||||
}
|
||||
|
||||
export async function getGitWebhookLogs(webhookId, limit = 50) {
|
||||
return this.request(`/git/webhooks/${webhookId}/logs?limit=${limit}`);
|
||||
return this.request(`/git/webhooks/${webhookId}/logs?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function testWebhook(webhookId) {
|
||||
@@ -324,7 +324,7 @@ export async function getGiteaStatus() {
|
||||
}
|
||||
|
||||
export async function getGiteaRepositories(limit = 50) {
|
||||
return this.request(`/git/repos?limit=${limit}`);
|
||||
return this.request(`/git/repos?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getGiteaBranches(owner, repo) {
|
||||
@@ -333,11 +333,11 @@ export async function getGiteaBranches(owner, repo) {
|
||||
|
||||
// Git Deployments
|
||||
export async function getAppDeployments(appId, limit = 20) {
|
||||
return this.request(`/git/deployments/app/${appId}?limit=${limit}`);
|
||||
return this.request(`/git/deployments/app/${appId}?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getDeployment(deploymentId, includeLogs = false) {
|
||||
return this.request(`/git/deployments/${deploymentId}?logs=${includeLogs}`);
|
||||
return this.request(`/git/deployments/${deploymentId}?logs=${encodeURIComponent(includeLogs)}`);
|
||||
}
|
||||
|
||||
export async function triggerGitDeploy(appId, branch = null) {
|
||||
@@ -355,7 +355,7 @@ export async function rollbackDeployment(appId, targetVersion = null) {
|
||||
}
|
||||
|
||||
export async function getWebhookDeployments(webhookId, limit = 20) {
|
||||
return this.request(`/git/deployments/webhook/${webhookId}?limit=${limit}`);
|
||||
return this.request(`/git/deployments/webhook/${webhookId}?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
// Domains endpoints
|
||||
@@ -410,7 +410,7 @@ export async function verifyDomain(domainId) {
|
||||
// prefill the "give this a subdomain" action.
|
||||
export async function suggestSubdomain(applicationId, base) {
|
||||
const q = base ? `&base=${encodeURIComponent(base)}` : '';
|
||||
return this.request(`/domains/suggest-subdomain?application_id=${applicationId}${q}`);
|
||||
return this.request(`/domains/suggest-subdomain?application_id=${encodeURIComponent(applicationId)}${q}`);
|
||||
}
|
||||
|
||||
// Base domains a site can be published under (<slug>.<base>), for the picker.
|
||||
@@ -482,7 +482,7 @@ export async function runStatusCheck(compId) {
|
||||
}
|
||||
|
||||
export async function getStatusCheckHistory(compId, hours = 24) {
|
||||
return this.request(`/status/components/${compId}/history?hours=${hours}`);
|
||||
return this.request(`/status/components/${compId}/history?hours=${encodeURIComponent(hours)}`);
|
||||
}
|
||||
|
||||
export async function getStatusPageIncidents(pageId) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Mounted by the backend at /api/v1/manifests.
|
||||
|
||||
export async function getManifest(projectId) {
|
||||
return this.request(`/manifests?project_id=${projectId}`);
|
||||
return this.request(`/manifests?project_id=${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
|
||||
export async function planManifest(projectId, body = {}) {
|
||||
@@ -20,5 +20,5 @@ export async function applyManifest(projectId, body = {}) {
|
||||
}
|
||||
|
||||
export async function scaffoldManifest(appId) {
|
||||
return this.request(`/manifests/scaffold?app_id=${appId}`);
|
||||
return this.request(`/manifests/scaffold?app_id=${encodeURIComponent(appId)}`);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function getMonitorHistory(monitorId, params = {}) {
|
||||
}
|
||||
|
||||
export async function getMonitorUptime(monitorId, days = 90) {
|
||||
return this.request(`/monitors/${monitorId}/uptime?days=${days}`);
|
||||
return this.request(`/monitors/${monitorId}/uptime?days=${encodeURIComponent(days)}`);
|
||||
}
|
||||
|
||||
// --- Incidents ---
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function disableFirewall(firewall = null) {
|
||||
}
|
||||
|
||||
export async function getFirewallRules(firewall = null) {
|
||||
const params = firewall ? `?firewall=${firewall}` : '';
|
||||
const params = firewall ? `?firewall=${encodeURIComponent(firewall)}` : '';
|
||||
return this.request(`/firewall/rules${params}`);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export async function getSSLHealth(domain) {
|
||||
}
|
||||
|
||||
export async function getSSLExpiryAlerts(days = 30) {
|
||||
return this.request(`/ssl/expiry-alerts?days=${days}`);
|
||||
return this.request(`/ssl/expiry-alerts?days=${encodeURIComponent(days)}`);
|
||||
}
|
||||
|
||||
// Security (ClamAV, File Integrity) endpoints
|
||||
@@ -193,15 +193,15 @@ export async function checkFileIntegrity() {
|
||||
}
|
||||
|
||||
export async function getFailedLogins(hours = 24) {
|
||||
return this.request(`/security/failed-logins?hours=${hours}`);
|
||||
return this.request(`/security/failed-logins?hours=${encodeURIComponent(hours)}`);
|
||||
}
|
||||
|
||||
export async function getSecurityEvents(limit = 100) {
|
||||
return this.request(`/security/events?limit=${limit}`);
|
||||
return this.request(`/security/events?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
// SSH Key endpoints
|
||||
export async function getSSHKeys(user = 'root') {
|
||||
return this.request(`/security/ssh-keys?user=${user}`);
|
||||
return this.request(`/security/ssh-keys?user=${encodeURIComponent(user)}`);
|
||||
}
|
||||
|
||||
export async function addSSHKey(key, user = 'root') {
|
||||
@@ -212,7 +212,7 @@ export async function addSSHKey(key, user = 'root') {
|
||||
}
|
||||
|
||||
export async function removeSSHKey(keyId, user = 'root') {
|
||||
return this.request(`/security/ssh-keys/${keyId}?user=${user}`, {
|
||||
return this.request(`/security/ssh-keys/${keyId}?user=${encodeURIComponent(user)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function deleteServerGroup(id) {
|
||||
|
||||
// Remote Docker Operations (via agent)
|
||||
export async function getRemoteContainers(serverId, all = false) {
|
||||
return this.request(`/servers/${serverId}/docker/containers?all=${all}`);
|
||||
return this.request(`/servers/${serverId}/docker/containers?all=${encodeURIComponent(all)}`);
|
||||
}
|
||||
|
||||
export async function getRemoteContainer(serverId, containerId) {
|
||||
@@ -143,7 +143,7 @@ export async function restartRemoteContainer(serverId, containerId) {
|
||||
}
|
||||
|
||||
export async function removeRemoteContainer(serverId, containerId, force = false) {
|
||||
return this.request(`/servers/${serverId}/docker/containers/${containerId}?force=${force}`, {
|
||||
return this.request(`/servers/${serverId}/docker/containers/${containerId}?force=${encodeURIComponent(force)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
@@ -170,7 +170,7 @@ export async function pullRemoteImage(serverId, image) {
|
||||
}
|
||||
|
||||
export async function removeRemoteImage(serverId, imageId, force = false) {
|
||||
return this.request(`/servers/${serverId}/docker/images/${imageId}?force=${force}`, {
|
||||
return this.request(`/servers/${serverId}/docker/images/${imageId}?force=${encodeURIComponent(force)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export async function getRemoteVolumes(serverId) {
|
||||
}
|
||||
|
||||
export async function removeRemoteVolume(serverId, volumeName, force = false) {
|
||||
return this.request(`/servers/${serverId}/docker/volumes/${encodeURIComponent(volumeName)}?force=${force}`, {
|
||||
return this.request(`/servers/${serverId}/docker/volumes/${encodeURIComponent(volumeName)}?force=${encodeURIComponent(force)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
@@ -518,16 +518,16 @@ export async function remoteComposePull(serverId, projectPath, service = null) {
|
||||
|
||||
// Server Historical Metrics
|
||||
export async function getServerMetricsHistory(serverId, period = '24h') {
|
||||
return this.request(`/servers/${serverId}/metrics/history?period=${period}`);
|
||||
return this.request(`/servers/${serverId}/metrics/history?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getServerMetricsAggregated(serverId, period = '24h', aggregation = 'hourly') {
|
||||
return this.request(`/servers/${serverId}/metrics/aggregated?period=${period}&aggregation=${aggregation}`);
|
||||
return this.request(`/servers/${serverId}/metrics/aggregated?period=${encodeURIComponent(period)}&aggregation=${encodeURIComponent(aggregation)}`);
|
||||
}
|
||||
|
||||
export async function compareServerMetrics(serverIds, metric = 'cpu', period = '24h') {
|
||||
const ids = Array.isArray(serverIds) ? serverIds.join(',') : serverIds;
|
||||
return this.request(`/servers/metrics/compare?ids=${ids}&metric=${metric}&period=${period}`);
|
||||
return this.request(`/servers/metrics/compare?ids=${encodeURIComponent(ids)}&metric=${encodeURIComponent(metric)}&period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getMetricsRetentionStats() {
|
||||
@@ -612,7 +612,7 @@ export async function getAllSecurityAlerts(options = {}) {
|
||||
}
|
||||
|
||||
export async function getSecurityAlertCounts(serverId = null) {
|
||||
const query = serverId ? `?server_id=${serverId}` : '';
|
||||
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
|
||||
return this.request(`/servers/security/alerts/counts${query}`);
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ export async function startRollout(data) {
|
||||
}
|
||||
|
||||
export async function getRollouts(status) {
|
||||
const query = status ? `?status=${status}` : '';
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return this.request(`/servers/fleet/rollouts${query}`);
|
||||
}
|
||||
|
||||
@@ -684,7 +684,7 @@ export async function cancelRollout(rolloutId) {
|
||||
}
|
||||
|
||||
export async function startDiscovery(duration = 10) {
|
||||
return this.request(`/servers/fleet/discovery?duration=${duration}`, {
|
||||
return this.request(`/servers/fleet/discovery?duration=${encodeURIComponent(duration)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
@@ -706,7 +706,7 @@ export async function rejectRegistration(serverId) {
|
||||
}
|
||||
|
||||
export async function getQueuedCommands(serverId) {
|
||||
const query = serverId ? `?server_id=${serverId}` : '';
|
||||
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
|
||||
return this.request(`/servers/fleet/commands/queued${query}`);
|
||||
}
|
||||
|
||||
@@ -722,13 +722,13 @@ export async function getServerDiagnostics(serverId) {
|
||||
|
||||
// Fleet Monitor (Cross-Server Monitoring) endpoints
|
||||
export async function getFleetHeatmap(groupId) {
|
||||
const query = groupId ? `?group_id=${groupId}` : '';
|
||||
const query = groupId ? `?group_id=${encodeURIComponent(groupId)}` : '';
|
||||
return this.request(`/fleet-monitor/heatmap${query}`);
|
||||
}
|
||||
|
||||
export async function getFleetComparison(serverIds, metric, period) {
|
||||
const ids = serverIds.join(',');
|
||||
return this.request(`/fleet-monitor/comparison?ids=${ids}&metric=${metric}&period=${period}`);
|
||||
return this.request(`/fleet-monitor/comparison?ids=${encodeURIComponent(ids)}&metric=${encodeURIComponent(metric)}&period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getFleetAlerts(params = {}) {
|
||||
@@ -750,7 +750,7 @@ export async function resolveFleetAlert(alertId) {
|
||||
}
|
||||
|
||||
export async function getFleetThresholds(serverId) {
|
||||
const query = serverId ? `?server_id=${serverId}` : '';
|
||||
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
|
||||
return this.request(`/fleet-monitor/thresholds${query}`);
|
||||
}
|
||||
|
||||
@@ -763,21 +763,21 @@ export async function deleteFleetThreshold(thresholdId) {
|
||||
}
|
||||
|
||||
export async function getFleetAnomalies(serverId) {
|
||||
const query = serverId ? `?server_id=${serverId}` : '';
|
||||
const query = serverId ? `?server_id=${encodeURIComponent(serverId)}` : '';
|
||||
return this.request(`/fleet-monitor/anomalies${query}`);
|
||||
}
|
||||
|
||||
export async function getCapacityForecast(serverId, metric = 'disk') {
|
||||
return this.request(`/fleet-monitor/forecast/${serverId}?metric=${metric}`);
|
||||
return this.request(`/fleet-monitor/forecast/${serverId}?metric=${encodeURIComponent(metric)}`);
|
||||
}
|
||||
|
||||
export async function searchFleet(query, type = 'any') {
|
||||
return this.request(`/fleet-monitor/search?q=${encodeURIComponent(query)}&type=${type}`);
|
||||
return this.request(`/fleet-monitor/search?q=${encodeURIComponent(query)}&type=${encodeURIComponent(type)}`);
|
||||
}
|
||||
|
||||
export async function exportFleetCsv(serverIds, metric, period) {
|
||||
const ids = serverIds.join(',');
|
||||
const url = `${this.baseUrl}/fleet-monitor/export/csv?ids=${ids}&metric=${metric}&period=${period}`;
|
||||
const url = `${this.baseUrl}/fleet-monitor/export/csv?ids=${encodeURIComponent(ids)}&metric=${encodeURIComponent(metric)}&period=${encodeURIComponent(period)}`;
|
||||
const token = this.getToken();
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
@@ -787,7 +787,7 @@ export async function exportFleetCsv(serverIds, metric, period) {
|
||||
|
||||
// Agent Plugins endpoints
|
||||
export async function getAgentPlugins(status) {
|
||||
const query = status ? `?status=${status}` : '';
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return this.request(`/agent-plugins/${query}`);
|
||||
}
|
||||
|
||||
@@ -851,7 +851,7 @@ export async function getPluginSpec() {
|
||||
|
||||
// Server Templates endpoints
|
||||
export async function getServerTemplates(category) {
|
||||
const query = category ? `?category=${category}` : '';
|
||||
const query = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.request(`/server-templates/${query}`);
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,7 @@ export async function getCloudProviderOptions(type) {
|
||||
}
|
||||
|
||||
export async function getCloudServers(providerId) {
|
||||
const query = providerId ? `?provider_id=${providerId}` : '';
|
||||
const query = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
|
||||
return this.request(`/cloud/servers${query}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Mounted by the backend at /api/v1/apps.
|
||||
|
||||
export async function getAppSnapshots(appId, limit = 50) {
|
||||
return this.request(`/apps/${appId}/snapshots?limit=${limit}`);
|
||||
return this.request(`/apps/${appId}/snapshots?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getAppSnapshot(appId, snapId) {
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function healthCheck() {
|
||||
|
||||
// Process endpoints
|
||||
export async function getProcesses(limit = 50, sortBy = 'cpu') {
|
||||
return this.request(`/processes?limit=${limit}&sort=${sortBy}`);
|
||||
return this.request(`/processes?limit=${encodeURIComponent(limit)}&sort=${encodeURIComponent(sortBy)}`);
|
||||
}
|
||||
|
||||
export async function getProcess(pid) {
|
||||
@@ -154,7 +154,7 @@ export async function getProcess(pid) {
|
||||
}
|
||||
|
||||
export async function killProcess(pid, force = false) {
|
||||
return this.request(`/processes/${pid}?force=${force}`, { method: 'DELETE' });
|
||||
return this.request(`/processes/${pid}?force=${encodeURIComponent(force)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getServicesStatus() {
|
||||
@@ -228,7 +228,7 @@ export async function previewNginxDiff(domain, config) {
|
||||
}
|
||||
|
||||
export async function getNginxVhostLogs(domain, type = 'access', lines = 100) {
|
||||
return this.request(`/nginx/advanced/logs/${domain}?type=${type}&lines=${lines}`);
|
||||
return this.request(`/nginx/advanced/logs/${domain}?type=${encodeURIComponent(type)}&lines=${encodeURIComponent(lines)}`);
|
||||
}
|
||||
|
||||
export async function getNginxLBMethods() {
|
||||
@@ -241,15 +241,15 @@ export async function getLogFiles() {
|
||||
}
|
||||
|
||||
export async function readLog(filepath, lines = 100) {
|
||||
return this.request(`/logs/read?path=${encodeURIComponent(filepath)}&lines=${lines}`);
|
||||
return this.request(`/logs/read?path=${encodeURIComponent(filepath)}&lines=${encodeURIComponent(lines)}`);
|
||||
}
|
||||
|
||||
export async function searchLog(filepath, pattern, lines = 100) {
|
||||
return this.request(`/logs/search?path=${encodeURIComponent(filepath)}&pattern=${encodeURIComponent(pattern)}&lines=${lines}`);
|
||||
return this.request(`/logs/search?path=${encodeURIComponent(filepath)}&pattern=${encodeURIComponent(pattern)}&lines=${encodeURIComponent(lines)}`);
|
||||
}
|
||||
|
||||
export async function getAppLogs(appName, type = 'access', lines = 100) {
|
||||
return this.request(`/logs/app/${appName}?type=${type}&lines=${lines}`);
|
||||
return this.request(`/logs/app/${appName}?type=${encodeURIComponent(type)}&lines=${encodeURIComponent(lines)}`);
|
||||
}
|
||||
|
||||
export async function getJournalLogs(unit, lines = 100) {
|
||||
@@ -431,7 +431,7 @@ export async function checkAlerts() {
|
||||
}
|
||||
|
||||
export async function getAlertHistory(limit = 100) {
|
||||
return this.request(`/monitoring/alerts/history?limit=${limit}`);
|
||||
return this.request(`/monitoring/alerts/history?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getMonitoringConfig() {
|
||||
@@ -480,7 +480,7 @@ export async function testWebhookAlert(webhookUrl) {
|
||||
|
||||
// Backup System endpoints
|
||||
export async function getBackups(type = null) {
|
||||
const params = type ? `?type=${type}` : '';
|
||||
const params = type ? `?type=${encodeURIComponent(type)}` : '';
|
||||
return this.request(`/backups${params}`);
|
||||
}
|
||||
|
||||
@@ -703,11 +703,11 @@ export async function getUptimeStats() {
|
||||
}
|
||||
|
||||
export async function getUptimeGraph(period = '24h') {
|
||||
return this.request(`/uptime/graph?period=${period}`);
|
||||
return this.request(`/uptime/graph?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getUptimeHistory(hours = 24) {
|
||||
return this.request(`/uptime/history?hours=${hours}`);
|
||||
return this.request(`/uptime/history?hours=${encodeURIComponent(hours)}`);
|
||||
}
|
||||
|
||||
export async function startUptimeTracking() {
|
||||
@@ -724,7 +724,7 @@ export async function getUptimeTrackingStatus() {
|
||||
|
||||
// Metrics History endpoints
|
||||
export async function getMetricsHistory(period = '1h') {
|
||||
return this.request(`/system/performance-history?period=${period}`);
|
||||
return this.request(`/system/performance-history?period=${encodeURIComponent(period)}`);
|
||||
}
|
||||
|
||||
export async function getMetricsStats() {
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function getTelemetryEventTypes(params = {}) {
|
||||
}
|
||||
|
||||
export async function cleanupTelemetryEvents(days = 90) {
|
||||
return this.request(`/telemetry/events?days=${days}`, { method: 'DELETE' });
|
||||
return this.request(`/telemetry/events?days=${encodeURIComponent(days)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function emitTestTelemetryEvent(data = {}) {
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function getTestSandboxDistros() {
|
||||
}
|
||||
|
||||
export async function getTestSandboxRuns(limit = 20) {
|
||||
return this.request(`/test-sandbox/runs?limit=${limit}`);
|
||||
return this.request(`/test-sandbox/runs?limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function startTestSandboxRun(distros, mode) {
|
||||
|
||||
@@ -2,41 +2,40 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { nextFire, untilLabel, frequencyLabel } from '../backupSchedule.js';
|
||||
|
||||
test('daily schedules use one clock and roll an elapsed slot across midnight', () => {
|
||||
const now = new Date(2026, 8, 5, 23, 45);
|
||||
const next = nextFire({ schedule_time: '00:15' }, now);
|
||||
assert.equal(next.getDate(), 6);
|
||||
assert.equal(untilLabel(next, now), 'in 30m');
|
||||
assert.equal(now.getDate(), 5);
|
||||
assert.equal(nextFire({ schedule_time: '23:45' }, now).getDate(), 6);
|
||||
});
|
||||
|
||||
test('weekly schedules accept full and abbreviated weekdays, including next week', () => {
|
||||
const now = new Date(2026, 8, 5, 12); // Saturday
|
||||
assert.equal(nextFire({ schedule_time: '13:00', days: ['Saturday'] }, now).getDate(), 5);
|
||||
assert.equal(nextFire({ schedule_time: '11:00', days: ['sat'] }, now).getDate(), 12);
|
||||
assert.equal(frequencyLabel({ schedule_time: '02:00', days: ['sunday'] }), 'Weekly · Sun 02:00');
|
||||
});
|
||||
|
||||
test('malformed schedules cannot silently normalize into a different run time', () => {
|
||||
const now = new Date(2026, 8, 5);
|
||||
for (const schedule_time of ['', '12', ':30', '24:00', '12:60', '-1:00', '12:30:00']) {
|
||||
assert.equal(nextFire({ schedule_time }, now), null, schedule_time);
|
||||
}
|
||||
assert.equal(nextFire({ schedule_time: '12:30', days: ['noday'] }, now), null);
|
||||
assert.equal(nextFire({ schedule_time: '12:30' }, new Date(NaN)), null);
|
||||
});
|
||||
|
||||
test('spring-forward skips a wall-clock slot the server cannot execute', () => {
|
||||
test('countdowns use the authoritative instant across browser timezones', () => {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
try {
|
||||
const now = new Date(2026, 2, 8, 0);
|
||||
const next = nextFire({ schedule_time: '02:30', days: ['daily'] }, now);
|
||||
assert.equal(next.getDate(), 9);
|
||||
assert.equal(next.getHours(), 2);
|
||||
for (const zone of ['UTC', 'America/Los_Angeles', 'Asia/Tokyo']) {
|
||||
process.env.TZ = zone;
|
||||
const next = nextFire({ schedule_time: '02:00', timezone: 'America/New_York', next_run_at: '2026-09-06T02:00:00-04:00' });
|
||||
assert.equal(next.toISOString(), '2026-09-06T06:00:00.000Z');
|
||||
assert.equal(untilLabel(next, new Date('2026-09-06T05:30:00Z')), 'in 30m');
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown, invalid, disabled and timezone-less next runs are never guessed', () => {
|
||||
for (const next_run_at of [undefined, null, '', 'invalid', '2026-09-06T02:00:00', 1788650000]) {
|
||||
assert.equal(nextFire({ schedule_time: '02:00', next_run_at }), null);
|
||||
}
|
||||
const next_run_at = '2026-09-06T02:00:00Z';
|
||||
assert.equal(nextFire({ enabled: false, next_run_at }), null);
|
||||
assert.equal(nextFire({ schedule_error: 'Invalid time', next_run_at }), null);
|
||||
assert.equal(nextFire({ enabled: true, next_run_at: null }), null); // Global pause.
|
||||
});
|
||||
|
||||
test('DST folds use the backend offset and due slots do not move to tomorrow', () => {
|
||||
const first = nextFire({ next_run_at: '2026-11-01T01:30:00-04:00' });
|
||||
const second = nextFire({ next_run_at: '2026-11-01T01:30:00-05:00' });
|
||||
assert.equal(second - first, 3600000);
|
||||
assert.equal(untilLabel(first, first), 'due now');
|
||||
assert.equal(untilLabel(first, second), 'due now');
|
||||
});
|
||||
|
||||
test('frequency labels retain the server wall time and state its timezone', () => {
|
||||
assert.equal(frequencyLabel({ schedule_time: '02:00', timezone: 'America/New_York' }), 'Daily · 02:00 America/New_York');
|
||||
assert.equal(frequencyLabel({ schedule_time: '02:00', days: ['sunday'] }), 'Weekly · Sun 02:00');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runRecipe } from '../runRecipe.js';
|
||||
|
||||
test('recipe start awaits the shared mutation contract before returning its job', async () => {
|
||||
const messages = [];
|
||||
let resolve;
|
||||
const body = { recipe_id: 'web', server_id: 7 };
|
||||
const pending = runRecipe({
|
||||
startRun: { mutate: (value) => {
|
||||
assert.equal(value, body);
|
||||
return new Promise((done) => { resolve = done; });
|
||||
} },
|
||||
toast: { success: (message) => messages.push(message), error: assert.fail },
|
||||
t: (_key, fallback, values) => fallback.replace('{{server}}', values.server),
|
||||
}, body, { serverName: 'Local' });
|
||||
assert.deepEqual(messages, []);
|
||||
resolve({ job_id: 'job-123' });
|
||||
assert.equal(await pending, 'job-123');
|
||||
assert.deepEqual(messages, ['Recipe started on Local.']);
|
||||
});
|
||||
|
||||
test('failed recipe starts return no navigation target and report one error', async () => {
|
||||
const messages = [];
|
||||
const job = await runRecipe({
|
||||
startRun: { mutate: async () => { throw new Error('Server is offline'); } },
|
||||
toast: { success: assert.fail, error: (message) => messages.push(message) },
|
||||
t: (_key, fallback) => fallback,
|
||||
}, {}, { serverName: 'Local' });
|
||||
assert.equal(job, null);
|
||||
assert.deepEqual(messages, ['Server is offline']);
|
||||
});
|
||||
@@ -3,31 +3,15 @@ const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const scheduleDays = (schedule) => (Array.isArray(schedule.days) ? schedule.days : ['daily']);
|
||||
const dayIndex = (day) => DAY_INDEX[String(day).slice(0, 3).toLowerCase()];
|
||||
|
||||
// Legacy schedules contain wall-clock time, without an IANA timezone. Execution
|
||||
// is server-local; this preserves the existing browser-local *estimate*. An
|
||||
// authoritative cross-zone countdown requires a next_run/timezone API field.
|
||||
// Use a single explicit clock for both calculation and wording in each render.
|
||||
export function nextFire(schedule, now) {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(schedule.schedule_time || ''));
|
||||
if (!match || !Number.isFinite(now?.getTime())) return null;
|
||||
const hh = Number(match[1]);
|
||||
const mm = Number(match[2]);
|
||||
if (hh > 23 || mm > 59) return null;
|
||||
const days = scheduleDays(schedule);
|
||||
const daily = days.length === 0 || days.includes('daily');
|
||||
const wanted = new Set(days.map(dayIndex).filter((day) => day != null));
|
||||
if (!daily && wanted.size === 0) return null;
|
||||
for (let offset = 0; offset <= 7; offset += 1) {
|
||||
const candidate = new Date(now);
|
||||
candidate.setDate(candidate.getDate() + offset);
|
||||
candidate.setHours(hh, mm, 0, 0);
|
||||
// Spring-forward can normalize a nonexistent 02:30 into 03:30. The
|
||||
// scheduler matches wall time exactly, so skip that day's absent slot.
|
||||
if (candidate.getHours() !== hh || candidate.getMinutes() !== mm) continue;
|
||||
if (candidate.getTime() <= now.getTime()) continue;
|
||||
if (daily || wanted.has(candidate.getDay())) return candidate;
|
||||
}
|
||||
return null;
|
||||
// The scheduler owns wall-clock/DST calculations. Never turn schedule_time
|
||||
// into a browser-local estimate: the browser and server may be in different
|
||||
// zones. Require an explicit offset so malformed/older responses stay unknown.
|
||||
export function nextFire(schedule) {
|
||||
const value = schedule.next_run_at;
|
||||
if (schedule.enabled === false || schedule.schedule_error || typeof value !== 'string'
|
||||
|| !/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) return null;
|
||||
const next = new Date(value);
|
||||
return Number.isFinite(next.getTime()) ? next : null;
|
||||
}
|
||||
|
||||
export function untilLabel(date, now) {
|
||||
@@ -41,7 +25,7 @@ export function untilLabel(date, now) {
|
||||
}
|
||||
|
||||
export function frequencyLabel(schedule) {
|
||||
const time = schedule.schedule_time || '—';
|
||||
const time = [schedule.schedule_time || '—', schedule.timezone].filter(Boolean).join(' ');
|
||||
const days = scheduleDays(schedule);
|
||||
if (days.length === 0 || days.includes('daily')) return `Daily · ${time}`;
|
||||
if (days.length === 1) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// useServerMutation exposes an async `mutate`; recipe navigation waits for its
|
||||
// job ID, while this adapter owns the catalog's success/failure messages.
|
||||
export async function runRecipe({ startRun, toast, t }, body, { serverName }) {
|
||||
try {
|
||||
const result = await startRun.mutate(body);
|
||||
toast.success(t('app.recipes.started', 'Recipe started on {{server}}.', {
|
||||
server: serverName,
|
||||
}));
|
||||
return result.job_id;
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('app.recipes.startFailed', 'Could not start the recipe'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproduce source counts without importing the app or opening its database.
|
||||
|
||||
Route declarations are source inventory, not live registered endpoint counts.
|
||||
Use --collect-tests with the backend Python environment for a clean-checkout
|
||||
pytest collection count. Collection does not mean the tests have passed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def source_counts(files):
|
||||
routes = 0
|
||||
blueprints = 0
|
||||
methods = 0
|
||||
dynamic_methods = 0
|
||||
for source in files:
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
name = getattr(node.func, 'id', None) or getattr(node.func, 'attr', None)
|
||||
if name == 'Blueprint':
|
||||
blueprints += 1
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for decorator in node.decorator_list:
|
||||
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute):
|
||||
continue
|
||||
name = decorator.func.attr
|
||||
if name not in ('route', 'get', 'post', 'put', 'patch', 'delete'):
|
||||
continue
|
||||
routes += 1
|
||||
if name != 'route':
|
||||
methods += 1
|
||||
continue
|
||||
keyword = next((kw for kw in decorator.keywords if kw.arg == 'methods'), None)
|
||||
try:
|
||||
values = ast.literal_eval(keyword.value) if keyword else ['GET']
|
||||
if not isinstance(values, (list, tuple, set)) or not all(isinstance(v, str) for v in values):
|
||||
raise ValueError('nonliteral methods')
|
||||
methods += len(set(values) - {'HEAD', 'OPTIONS'})
|
||||
except (ValueError, TypeError):
|
||||
dynamic_methods += 1
|
||||
return {
|
||||
'core_route_declarations': routes,
|
||||
'core_blueprint_declarations': blueprints,
|
||||
'explicit_method_route_pairs_excluding_head_options': methods,
|
||||
'route_declarations_with_dynamic_methods': dynamic_methods,
|
||||
}
|
||||
|
||||
|
||||
def collect_tests():
|
||||
spec = importlib.util.spec_from_file_location('count_tests', ROOT / 'backend/tests/check_test_count.py')
|
||||
collector = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(collector)
|
||||
# conftest normally sweeps shared temp databases. A private URL avoids
|
||||
# touching another developer/test process's scratch database during census.
|
||||
with tempfile.TemporaryDirectory(prefix='serverkit-census-') as directory:
|
||||
before = os.environ.get('TEST_DATABASE_URL')
|
||||
os.environ['TEST_DATABASE_URL'] = 'sqlite:///' + (Path(directory) / 'tests.db').as_posix()
|
||||
try:
|
||||
return collector.collect_count()
|
||||
finally:
|
||||
if before is None:
|
||||
os.environ.pop('TEST_DATABASE_URL', None)
|
||||
else:
|
||||
os.environ['TEST_DATABASE_URL'] = before
|
||||
|
||||
|
||||
def measure(include_tests=False):
|
||||
tracked = subprocess.check_output(
|
||||
['git', 'ls-files', '-z'], cwd=ROOT, stderr=subprocess.DEVNULL,
|
||||
).decode('utf-8').split('\0')
|
||||
api_paths = sorted(p for p in tracked if p.startswith('backend/app/api/') and p.endswith('.py'))
|
||||
templates = sorted(p for p in tracked if Path(p).parent.as_posix() == 'backend/templates'
|
||||
and Path(p).suffix in ('.yaml', '.yml'))
|
||||
fingerprint = hashlib.sha256()
|
||||
for path in api_paths + templates:
|
||||
fingerprint.update(path.encode('utf-8') + b'\0' + (ROOT / path).read_bytes() + b'\0')
|
||||
return {
|
||||
'schema_version': 1,
|
||||
'measured_at_utc': datetime.now(timezone.utc).isoformat(),
|
||||
'source_revision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
|
||||
'measured_source_has_uncommitted_changes': subprocess.run(
|
||||
['git', 'diff', '--quiet', 'HEAD', '--', 'backend/app/api', 'backend/templates'],
|
||||
cwd=ROOT, check=False,
|
||||
).returncode != 0,
|
||||
'measured_source_sha256': fingerprint.hexdigest(),
|
||||
'scope': 'Git-tracked core API Python declarations and root-level bundled app YAML templates; excludes extensions, dependencies and generated/install copies.',
|
||||
'python_version': platform.python_version(),
|
||||
'platform': platform.system(),
|
||||
**source_counts([(ROOT / path).read_text(encoding='utf-8-sig') for path in api_paths]),
|
||||
'core_api_source_files': len(api_paths),
|
||||
'bundled_app_templates': len(templates),
|
||||
'backend_tests_clean_collected': collect_tests() if include_tests else None,
|
||||
'collection_scope': 'backend/tests, SERVERKIT_CLEAN_COLLECT=1; collected cases, not passed tests',
|
||||
'runtime_memory_bytes': None,
|
||||
'container_image_bytes': None,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--collect-tests', action='store_true')
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args()
|
||||
report = json.dumps(measure(args.collect_tests), indent=2) + '\n'
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(report, encoding='utf-8')
|
||||
else:
|
||||
print(report, end='')
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure real GET requests; use only against an authorized local/staging panel.
|
||||
|
||||
JSON schema version 1: endpoints contain warmup and samples arrays plus summary.
|
||||
Every attempt records elapsed_ms, status, response_bytes, app_ms, sql_ms,
|
||||
sql_queries and error (null on success). Missing Server-Timing data stays null.
|
||||
Summary distributions contain count/min/p50/p95/max, with linearly interpolated
|
||||
percentiles. Summaries exclude warmup; request_count includes all attempts.
|
||||
Body contents, credentials and query values are never included in reports.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import ipaddress
|
||||
from http.client import HTTPException
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
|
||||
class NoRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
position = (len(ordered) - 1) * fraction
|
||||
lower = math.floor(position)
|
||||
upper = math.ceil(position)
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
||||
|
||||
|
||||
def distribution(values):
|
||||
present = [value for value in values if value is not None]
|
||||
return {
|
||||
'count': len(present), 'min': min(present) if present else None,
|
||||
'p50': percentile(present, .5), 'p95': percentile(present, .95),
|
||||
'max': max(present) if present else None,
|
||||
}
|
||||
|
||||
|
||||
def redact_path(path):
|
||||
parsed = urlsplit(path)
|
||||
query = urlencode([(key, '[redacted]') for key, _ in parse_qsl(parsed.query, keep_blank_values=True)])
|
||||
return urlunsplit(('', '', parsed.path, query, ''))
|
||||
|
||||
|
||||
def validate_target(base_url, paths, authenticated):
|
||||
base = urlsplit(base_url)
|
||||
if (base.scheme not in ('http', 'https') or not base.hostname or base.username is not None
|
||||
or base.password is not None or base.query or base.fragment):
|
||||
raise ValueError('base-url must be an HTTP(S) URL without credentials, query or fragment')
|
||||
try:
|
||||
base.port
|
||||
except ValueError:
|
||||
raise ValueError('base-url has an invalid port') from None
|
||||
loopback = base.hostname.lower() == 'localhost'
|
||||
try:
|
||||
loopback = loopback or ipaddress.ip_address(base.hostname).is_loopback
|
||||
except ValueError:
|
||||
pass
|
||||
if authenticated and base.scheme == 'http' and not loopback:
|
||||
raise ValueError('credentials require HTTPS except for a loopback host')
|
||||
if any(ord(char) < 32 for char in base_url):
|
||||
raise ValueError('base-url contains control characters')
|
||||
for path in paths:
|
||||
parsed = urlsplit(path)
|
||||
decoded = unquote(parsed.path)
|
||||
if (parsed.scheme or parsed.netloc or parsed.fragment or not decoded.startswith('/api/')
|
||||
or '\\' in decoded or any(part in ('.', '..') for part in decoded.split('/'))
|
||||
or any(ord(char) < 32 for char in path + decoded)):
|
||||
raise ValueError('each path must be a relative /api/ URL without traversal or fragments')
|
||||
return base_url.rstrip('/')
|
||||
|
||||
|
||||
def parse_server_timing(header):
|
||||
result = {'app_ms': None, 'sql_ms': None, 'sql_queries': None}
|
||||
# The panel emits unquoted numeric durations and a quoted query count.
|
||||
# Ignore unrelated metrics or malformed values without inventing zeros.
|
||||
for match in re.finditer(r'(?:^|,)\s*(app|db)\s*((?:;[^,]*)?)', header or ''):
|
||||
name, params = match.groups()
|
||||
duration = re.search(r'(?:^|;)\s*dur\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*(?:;|$)', params)
|
||||
if duration:
|
||||
value = float(duration.group(1))
|
||||
if math.isfinite(value):
|
||||
result['app_ms' if name == 'app' else 'sql_ms'] = value
|
||||
if name == 'db':
|
||||
queries = re.search(r'(?:^|;)\s*desc\s*=\s*"([0-9]+) queries"', params)
|
||||
if queries:
|
||||
result['sql_queries'] = int(queries.group(1))
|
||||
return result
|
||||
|
||||
|
||||
def sample_get(opener, url, headers, timeout):
|
||||
started = time.perf_counter()
|
||||
sample = {'elapsed_ms': None, 'status': None, 'response_bytes': None,
|
||||
'app_ms': None, 'sql_ms': None, 'sql_queries': None, 'error': None}
|
||||
response = None
|
||||
try:
|
||||
try:
|
||||
response = opener.open(Request(url, headers=headers, method='GET'), timeout=timeout)
|
||||
except HTTPError as exc:
|
||||
response = exc
|
||||
sample['status'] = response.status
|
||||
sample.update(parse_server_timing(', '.join(response.headers.get_all('Server-Timing', []))))
|
||||
if 300 <= response.status < 400:
|
||||
sample['error'] = 'redirect_refused'
|
||||
elif not 200 <= response.status < 300:
|
||||
sample['error'] = 'http_error'
|
||||
size = 0
|
||||
while True:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
sample['response_bytes'] = size
|
||||
except (OSError, URLError, ValueError, HTTPException) as exc:
|
||||
# Exception messages may contain the URL/query or proxy credentials.
|
||||
sample['error'] = f'transport_error:{type(exc).__name__}'
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
sample['elapsed_ms'] = (time.perf_counter() - started) * 1000
|
||||
return sample
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
statuses = {}
|
||||
for sample in samples:
|
||||
key = str(sample['status']) if sample['status'] is not None else 'unavailable'
|
||||
statuses[key] = statuses.get(key, 0) + 1
|
||||
return {
|
||||
'request_count': len(samples), 'failed_count': sum(bool(sample['error']) for sample in samples),
|
||||
'statuses': statuses,
|
||||
**{field: distribution([sample[field] for sample in samples])
|
||||
for field in ('elapsed_ms', 'response_bytes', 'app_ms', 'sql_ms', 'sql_queries')},
|
||||
}
|
||||
|
||||
|
||||
def profile(base_url, paths, *, samples=5, warmup=1, timeout=30, headers=None):
|
||||
headers = headers or {}
|
||||
base_url = validate_target(base_url, paths, bool(headers))
|
||||
# Direct requests avoid implicit proxy authentication/routing from the shell.
|
||||
opener = build_opener(ProxyHandler({}), NoRedirects())
|
||||
report = {'schema_version': 1, 'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'base_url': base_url, 'method': 'GET', 'request_count': 0,
|
||||
'failed_count': 0, 'endpoints': []}
|
||||
for path in paths:
|
||||
endpoint = {'path': redact_path(path), 'warmup': [], 'samples': []}
|
||||
for phase, count in (('warmup', warmup), ('samples', samples)):
|
||||
for _ in range(count):
|
||||
sample = sample_get(opener, base_url + path, headers, timeout)
|
||||
endpoint[phase].append(sample)
|
||||
report['request_count'] += 1
|
||||
report['failed_count'] += bool(sample['error'])
|
||||
endpoint['summary'] = summarize(endpoint['samples'])
|
||||
report['endpoints'].append(endpoint)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--base-url', default='http://127.0.0.1:47927')
|
||||
parser.add_argument('--path', action='append', help='repeatable relative /api/ URL; default /api/v1/system/health')
|
||||
parser.add_argument('--samples', type=int, default=5)
|
||||
parser.add_argument('--warmup', type=int, default=1)
|
||||
parser.add_argument('--timeout', type=float, default=30, help='request timeout in seconds (default 30)')
|
||||
parser.add_argument('--output', type=Path, help='write JSON to this file instead of stdout')
|
||||
parser.epilog = ('Optional authentication: SERVERKIT_PROFILE_TOKEN or SERVERKIT_PROFILE_API_KEY environment '
|
||||
'variable (mutually exclusive). Redirects are refused; credentials require HTTPS except '
|
||||
'on loopback. Exit 1 means one or more attempts failed, including warmup.')
|
||||
args = parser.parse_args(argv)
|
||||
if args.samples < 1 or args.warmup < 0 or not math.isfinite(args.timeout) or args.timeout <= 0:
|
||||
parser.error('samples must be positive, warmup nonnegative, and timeout finite and positive')
|
||||
token = os.environ.get('SERVERKIT_PROFILE_TOKEN')
|
||||
api_key = os.environ.get('SERVERKIT_PROFILE_API_KEY')
|
||||
if token and api_key:
|
||||
parser.error('set only one of SERVERKIT_PROFILE_TOKEN and SERVERKIT_PROFILE_API_KEY')
|
||||
headers = {'Authorization': f'Bearer {token}'} if token else {'X-API-Key': api_key} if api_key else {}
|
||||
if any('\r' in value or '\n' in value for value in headers.values()):
|
||||
parser.error('authentication value contains a newline')
|
||||
try:
|
||||
report = profile(args.base_url, args.path or ['/api/v1/system/health'], samples=args.samples,
|
||||
warmup=args.warmup, timeout=args.timeout, headers=headers)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
output = json.dumps(report, indent=2, allow_nan=False) + '\n'
|
||||
if args.output:
|
||||
args.output.write_text(output, encoding='utf-8')
|
||||
else:
|
||||
print(output, end='')
|
||||
return 1 if report['failed_count'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Stdlib-only tests: python -m unittest discover -s scripts/test -p test_profile_api.py."""
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location('profile_api', Path(__file__).parents[1] / 'profile-api.py')
|
||||
profile_api = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(profile_api)
|
||||
|
||||
|
||||
class MetricsTests(unittest.TestCase):
|
||||
def test_percentiles_and_absent_metrics(self):
|
||||
self.assertEqual(profile_api.percentile([30, 10, 20], .5), 20)
|
||||
self.assertEqual(profile_api.percentile([10, 20, 30], .95), 29)
|
||||
self.assertEqual(profile_api.percentile([10], .95), 10)
|
||||
self.assertEqual(profile_api.distribution([None, None]),
|
||||
{'count': 0, 'min': None, 'p50': None, 'p95': None, 'max': None})
|
||||
|
||||
def test_header_and_missing_header(self):
|
||||
self.assertEqual(profile_api.parse_server_timing('app;dur=12.50, db;dur=2.100;desc="4 queries"'),
|
||||
{'app_ms': 12.5, 'sql_ms': 2.1, 'sql_queries': 4})
|
||||
self.assertEqual(profile_api.parse_server_timing(None),
|
||||
{'app_ms': None, 'sql_ms': None, 'sql_queries': None})
|
||||
self.assertEqual(profile_api.parse_server_timing('app;dur=nan, db;dur=-1'),
|
||||
{'app_ms': None, 'sql_ms': None, 'sql_queries': None})
|
||||
|
||||
def test_query_values_redacted_including_repeated_and_empty(self):
|
||||
redacted = profile_api.redact_path('/api/v1/find?search=secret+text&search=password&empty=&flag')
|
||||
self.assertEqual(redacted, '/api/v1/find?search=%5Bredacted%5D&search=%5Bredacted%5D&empty=%5Bredacted%5D&flag=%5Bredacted%5D')
|
||||
|
||||
def test_targets_reject_auth_leaks_and_non_api_paths(self):
|
||||
for base in ['http://example.com', 'https://user:secret@example.com',
|
||||
'https://example.com?secret=key', 'ftp://localhost']:
|
||||
with self.subTest(base=base), self.assertRaises(ValueError):
|
||||
profile_api.validate_target(base, ['/api/v1/health'], True)
|
||||
for path in ['https://other.test/api/v1', '//other.test/api/v1', '/api/../admin',
|
||||
'/api/%2e%2e/admin', '/admin', '/api/v1#secret', '/api/%0dheader']:
|
||||
with self.subTest(path=path), self.assertRaises(ValueError):
|
||||
profile_api.validate_target('https://example.com', [path], False)
|
||||
for base in ['http://localhost:1234', 'http://127.0.0.1:1234', 'http://[::1]:1234', 'https://example.com']:
|
||||
self.assertEqual(profile_api.validate_target(base, ['/api/v1/health'], True), base)
|
||||
|
||||
|
||||
class LocalHTTPTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.requests = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
cls.requests.append((self.path, self.headers.get('Authorization'), self.headers.get('X-API-Key')))
|
||||
if self.path.startswith('/api/redirect'):
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/api/secret-destination')
|
||||
elif self.path.startswith('/api/fail'):
|
||||
self.send_response(503)
|
||||
else:
|
||||
self.send_response(200)
|
||||
if self.path.startswith('/api/profiled'):
|
||||
self.send_header('Server-Timing', 'app;dur=12.5, db;dur=2.5;desc="3 queries"')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'private response body')
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
cls.base = f'http://127.0.0.1:{cls.server.server_port}'
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=5)
|
||||
|
||||
def setUp(self):
|
||||
self.requests.clear()
|
||||
|
||||
def test_real_samples_warmup_bytes_headers_and_redaction(self):
|
||||
result = profile_api.profile(self.base, ['/api/profiled?search=hidden-query'],
|
||||
samples=3, warmup=1, headers={'Authorization': 'Bearer hidden-token'})
|
||||
endpoint = result['endpoints'][0]
|
||||
self.assertEqual(result['request_count'], 4)
|
||||
self.assertEqual(endpoint['summary']['request_count'], 3)
|
||||
self.assertEqual(endpoint['summary']['statuses'], {'200': 3})
|
||||
self.assertEqual(endpoint['summary']['response_bytes']['min'], len(b'private response body'))
|
||||
self.assertEqual(endpoint['summary']['sql_queries']['p95'], 3)
|
||||
self.assertGreater(endpoint['summary']['elapsed_ms']['min'], 0)
|
||||
self.assertEqual(self.requests[0], ('/api/profiled?search=hidden-query', 'Bearer hidden-token', None))
|
||||
serialized = json.dumps(result)
|
||||
for secret in ['hidden-query', 'hidden-token', 'private response body']:
|
||||
self.assertNotIn(secret, serialized)
|
||||
|
||||
def test_missing_header_stays_null(self):
|
||||
report = profile_api.profile(self.base, ['/api/plain'], samples=1, warmup=0)
|
||||
endpoint = report['endpoints'][0]
|
||||
self.assertIsNone(endpoint['samples'][0]['sql_queries'])
|
||||
self.assertIsNone(endpoint['summary']['app_ms']['p50'])
|
||||
|
||||
def test_http_failure_and_redirect_are_reported_without_following(self):
|
||||
report = profile_api.profile(self.base, ['/api/redirect', '/api/fail'], samples=1, warmup=0,
|
||||
headers={'X-API-Key': 'secret-key'})
|
||||
self.assertEqual(report['failed_count'], 2)
|
||||
self.assertEqual([entry['samples'][0]['error'] for entry in report['endpoints']],
|
||||
['redirect_refused', 'http_error'])
|
||||
self.assertEqual([path for path, _, _ in self.requests], ['/api/redirect', '/api/fail'])
|
||||
self.assertNotIn('secret-key', json.dumps(report))
|
||||
|
||||
def test_cli_failure_exit_and_mutually_exclusive_credentials(self):
|
||||
output = io.StringIO()
|
||||
with patch.dict(os.environ, {}, clear=True), redirect_stdout(output):
|
||||
code = profile_api.main(['--base-url', self.base, '--path', '/api/fail', '--samples', '1', '--warmup', '0'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(json.loads(output.getvalue())['failed_count'], 1)
|
||||
with patch.dict(os.environ, {'SERVERKIT_PROFILE_TOKEN': 'one', 'SERVERKIT_PROFILE_API_KEY': 'two'}):
|
||||
with patch('sys.stderr', io.StringIO()), self.assertRaises(SystemExit) as failure:
|
||||
profile_api.main([])
|
||||
self.assertEqual(failure.exception.code, 2)
|
||||
|
||||
def test_transport_error_does_not_echo_exception_url(self):
|
||||
opener = profile_api.build_opener()
|
||||
with patch.object(opener, 'open', side_effect=profile_api.URLError('https://host?token=secret')):
|
||||
sample = profile_api.sample_get(opener, self.base + '/api/test?token=secret', {}, 1)
|
||||
self.assertEqual(sample['error'], 'transport_error:URLError')
|
||||
self.assertIsNone(sample['status'])
|
||||
self.assertIsNone(sample['response_bytes'])
|
||||
self.assertNotIn('secret', json.dumps(sample))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user