Files
ServerKit/backend/app/models/server_template.py
T
Juan DenisandClaude Opus 5 2b4e360a2c feat(ui): incidents absorbs fleet alerts, and four more pages join the convention
Incidents was showing the same idea twice: a converged "All incidents" table, and
under it a second table headed FLEET ALERTS with its own Active/Acknowledged/
Resolved segment row. A host alert IS an incident whose source is a host alert —
the SOURCE column already said so. FleetAlertsPanel is deleted (188 lines, and
Incidents was its only importer); alerts are folded into the existing row merge
with server -> subject, metric/value/threshold -> what happened, severity ->
impact, status -> state. Ack and Resolve move into the row-actions cell, shown
only for rows that have an alert id and are not resolved. The segment became an
"Acknowledged" builtin view alongside the Active/Resolved ones that already
covered its other two buckets.

Config Templates: the Compliant/Drifted/Unknown KPI band and its progress bar are
gone, replaced by a per-row Compliance column plus Drifted/Compliant builtin
views. The band could say three assignments had drifted but never which template
caused it; the column answers that per row. This needed the template payload to
carry its own tallies, which is the same single query the bare assignment count
already cost.

Recycle bin: the All / domains / saved-views segment row is gone — those buckets
are a filter on the Type column, and the Type column's own menu does it with live
per-value counts and can be saved into a view. Settings tab panes now sit on the
same card surface every other table in the app does.

Users: Add User and Invite User move onto the chrome line next to the filter and
"..." instead of each occupying a row of their own above its table.

SSL certificates: was a card list with a KPI band, one tab away from /domains
being a full table. Now a table with saved views (Expiring soon / Valid / Failed
or missing), search, column filters and export. The leading tile takes the row's
state too — a fixed green shield beside an expired certificate contradicted the
Status cell next to it.

Service detail: the Environment and Events tabs become tables with the inline
chrome an inner tab gets.

Fixes found by verification, in code this round touched:
- ServerTemplates had four columns with a `value` accessor and no `render`, so
  DataTable fell back to row[key]: two rendered blank, one printed a raw array,
  and `services` printed a list of OBJECTS, which React refuses to render — that
  one took the whole tab down for any template declaring a service. Pre-existing,
  and invisible because the screenshot fixture sent `services: ['sshd']` as
  strings. The fixture now matches to_dict() so it cannot hide it again.
- useTopbarChrome returns a ready `actions` value. All eight hoisting pages
  destructured only `portal` and dropped the fallback, so outside a tab group the
  filter button and "..." would have gone nowhere rather than inline.
- sameViewState compares only the `page` keys a page still captures. Retiring a
  key (as the recycle bin just did) otherwise stranded every view a user had
  already saved with it, pinning "Unsaved" on with no way to clear it.
- The environment tab's "Applies to" column was gated on the app's CURRENT
  compose services, so a variable targeting a since-renamed service showed
  nowhere and read as applying to everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:03:03 -04:00

206 lines
7.7 KiB
Python

from datetime import datetime
from app import db
import json
class ServerTemplate(db.Model):
"""Defines expected state for a server — packages, services, firewall rules, users, files."""
__tablename__ = 'server_templates'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False, unique=True)
description = db.Column(db.Text)
category = db.Column(db.String(64), default='general') # web, database, mail, custom
# Template version tracking
version = db.Column(db.Integer, default=1)
# Inheritance
parent_id = db.Column(db.Integer, db.ForeignKey('server_templates.id'), nullable=True)
parent = db.relationship('ServerTemplate', remote_side=[id], backref='children')
# Expected state specification
packages_json = db.Column(db.Text) # ["nginx", "php8.1-fpm", ...]
services_json = db.Column(db.Text) # [{"name": "nginx", "enabled": true, "running": true}, ...]
firewall_rules_json = db.Column(db.Text) # [{"port": 80, "protocol": "tcp", "action": "allow"}, ...]
files_json = db.Column(db.Text) # [{"path": "/etc/nginx/...", "content_hash": "...", "mode": "0644"}, ...]
users_json = db.Column(db.Text) # [{"name": "www-data", "groups": ["www-data"]}, ...]
sysctl_json = db.Column(db.Text) # [{"key": "net.ipv4.ip_forward", "value": "1"}, ...]
# Auto-remediation
auto_remediate = db.Column(db.Boolean, default=False)
remediation_approval_required = db.Column(db.Boolean, default=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
assignments = db.relationship('ServerTemplateAssignment', backref='template', lazy='dynamic')
def _get_json(self, field):
val = getattr(self, field)
return json.loads(val) if val else []
def _set_json(self, field, value):
setattr(self, field, json.dumps(value))
@property
def packages(self):
return self._get_json('packages_json')
@packages.setter
def packages(self, v):
self._set_json('packages_json', v)
@property
def services(self):
return self._get_json('services_json')
@services.setter
def services(self, v):
self._set_json('services_json', v)
@property
def firewall_rules(self):
return self._get_json('firewall_rules_json')
@firewall_rules.setter
def firewall_rules(self, v):
self._set_json('firewall_rules_json', v)
@property
def files(self):
return self._get_json('files_json')
@files.setter
def files(self, v):
self._set_json('files_json', v)
@property
def users(self):
return self._get_json('users_json')
@users.setter
def users(self, v):
self._set_json('users_json', v)
@property
def sysctl_params(self):
return self._get_json('sysctl_json')
@sysctl_params.setter
def sysctl_params(self, v):
self._set_json('sysctl_json', v)
def get_merged_spec(self):
"""Get full spec including inherited fields from parent."""
if not self.parent:
return {
'packages': self.packages,
'services': self.services,
'firewall_rules': self.firewall_rules,
'files': self.files,
'users': self.users,
'sysctl_params': self.sysctl_params,
}
parent_spec = self.parent.get_merged_spec()
# Child overrides parent
for key in ['packages', 'services', 'firewall_rules', 'files', 'users', 'sysctl_params']:
child_val = getattr(self, key)
if child_val:
if key == 'packages':
parent_spec[key] = list(set(parent_spec.get(key, []) + child_val))
else:
parent_spec[key] = child_val
return parent_spec
def to_dict(self):
# Compliance is recorded per ASSIGNMENT, but the question asked of a
# template list is "which template is drifting?" — so each row carries
# its own tally. Pulling the statuses in one go costs the same single
# query the bare assignment count used to.
statuses = [s for (s,) in self.assignments.with_entities(
ServerTemplateAssignment.status)]
return {
'id': self.id,
'name': self.name,
'description': self.description,
'category': self.category,
'version': self.version,
'parent_id': self.parent_id,
'parent_name': self.parent.name if self.parent else None,
'packages': self.packages,
'services': self.services,
'firewall_rules': self.firewall_rules,
'files': self.files,
'users': self.users,
'sysctl_params': self.sysctl_params,
'auto_remediate': self.auto_remediate,
'remediation_approval_required': self.remediation_approval_required,
'assignment_count': len(statuses),
'compliant_count': statuses.count(ServerTemplateAssignment.STATUS_COMPLIANT),
'drifted_count': statuses.count(ServerTemplateAssignment.STATUS_DRIFTED),
'created_by': self.created_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<ServerTemplate {self.name} v{self.version}>'
class ServerTemplateAssignment(db.Model):
"""Tracks which template is applied to which server."""
__tablename__ = 'server_template_assignments'
id = db.Column(db.Integer, primary_key=True)
template_id = db.Column(db.Integer, db.ForeignKey('server_templates.id'), nullable=False)
server_id = db.Column(db.Integer, db.ForeignKey('servers.id'), nullable=False)
# Compliance status
STATUS_COMPLIANT = 'compliant'
STATUS_DRIFTED = 'drifted'
STATUS_CHECKING = 'checking'
STATUS_REMEDIATING = 'remediating'
STATUS_UNKNOWN = 'unknown'
status = db.Column(db.String(32), default=STATUS_UNKNOWN)
# Drift details
drift_report_json = db.Column(db.Text)
last_check_at = db.Column(db.DateTime)
last_remediation_at = db.Column(db.DateTime)
applied_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
server = db.relationship('Server', backref=db.backref('template_assignments', lazy='dynamic'))
__table_args__ = (
db.UniqueConstraint('template_id', 'server_id', name='uq_template_server'),
)
@property
def drift_report(self):
return json.loads(self.drift_report_json) if self.drift_report_json else {}
@drift_report.setter
def drift_report(self, v):
self.drift_report_json = json.dumps(v)
def to_dict(self):
return {
'id': self.id,
'template_id': self.template_id,
'template_name': self.template.name if self.template else None,
'server_id': self.server_id,
'server_name': self.server.name if self.server else None,
'status': self.status,
'drift_report': self.drift_report,
'last_check_at': self.last_check_at.isoformat() if self.last_check_at else None,
'last_remediation_at': self.last_remediation_at.isoformat() if self.last_remediation_at else None,
'applied_at': self.applied_at.isoformat() if self.applied_at else None,
}
def __repr__(self):
return f'<ServerTemplateAssignment template={self.template_id} server={self.server_id}>'