diff --git a/library/Director/Db/Branch/BranchMerger.php b/library/Director/Db/Branch/BranchMerger.php index 8c8c76fe..5c99c42d 100644 --- a/library/Director/Db/Branch/BranchMerger.php +++ b/library/Director/Db/Branch/BranchMerger.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Director\Db\Branch; use Icinga\Module\Director\Data\Db\DbObject; use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry; use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorActivityLog; use Ramsey\Uuid\UuidInterface; class BranchMerger @@ -82,9 +83,10 @@ class BranchMerger /** * @throws MergeError */ - public function merge() + public function merge($comment) { - $this->connection->runFailSafeTransaction(function () { + $this->connection->runFailSafeTransaction(function () use ($comment) { + $formerActivityId = (int) DirectorActivityLog::loadLatest($this->connection)->get('id'); $query = $this->db->select() ->from(BranchActivity::DB_TABLE) ->where('branch_uuid = ?', $this->connection->quoteBinary($this->branchUuid->getBytes())) @@ -95,6 +97,15 @@ class BranchMerger $this->applyModification($activity); } (new BranchStore($this->connection))->deleteByUuid($this->branchUuid); + $currentActivityId = (int) DirectorActivityLog::loadLatest($this->connection)->get('id'); + $firstActivityId = (int) $this->db->fetchOne( + $this->db->select()->from('director_activity_log', 'MIN(id)')->where('id > ?', $formerActivityId) + ); + $this->db->insert('director_activity_log_remark', [ + 'first_related_activity' => $firstActivityId, + 'last_related_activity' => $currentActivityId, + 'remark' => $comment, + ]); }); } diff --git a/library/Director/Web/Table/ActivityLogTable.php b/library/Director/Web/Table/ActivityLogTable.php index d7bfc6fe..76a703ae 100644 --- a/library/Director/Web/Table/ActivityLogTable.php +++ b/library/Director/Web/Table/ActivityLogTable.php @@ -3,10 +3,11 @@ namespace Icinga\Module\Director\Web\Table; use gipfl\Format\LocalTimeFormat; -use Icinga\Module\Director\Util; -use ipl\Html\BaseHtmlElement; use gipfl\IcingaWeb2\Link; use gipfl\IcingaWeb2\Table\ZfQueryBasedTable; +use Icinga\Module\Director\Util; +use ipl\Html\Html; +use ipl\Html\HtmlElement; class ActivityLogTable extends ZfQueryBasedTable { @@ -20,12 +21,6 @@ class ActivityLogTable extends ZfQueryBasedTable protected $hasObjectFilter = false; - /** @var BaseHtmlElement */ - protected $currentHead; - - /** @var BaseHtmlElement */ - protected $currentBody; - protected $searchColumns = [ 'author', 'object_name', @@ -35,6 +30,17 @@ class ActivityLogTable extends ZfQueryBasedTable /** @var LocalTimeFormat */ protected $timeFormat; + protected $ranges = []; + + /** @var ?object */ + protected $currentRange = null; + /** @var ?HtmlElement */ + protected $currentRangeCell = null; + /** @var int */ + protected $rangeRows = 0; + protected $continueRange = false; + protected $currentRow; + public function __construct($db) { parent::__construct($db); @@ -52,8 +58,27 @@ class ActivityLogTable extends ZfQueryBasedTable return $this; } + protected function fetchQueryRows() + { + $rows = parent::fetchQueryRows(); + // Hint -> DESC, that's why they are inverted + $last = $rows[0]->id; + $first = $rows[count($rows) - 1]->id; + $db = $this->db(); + $this->ranges = $db->fetchAll( + $db->select() + ->from('director_activity_log_remark') + ->where('first_related_activity <= ?', $last) + ->where('last_related_activity >= ?', $first) + ); + + return $rows; + } + + public function renderRow($row) { + $this->currentRow = $row; $this->splitByDay($row->ts_change_time); $action = 'action-' . $row->action. ' '; if ($row->id > $this->lastDeployedId) { @@ -62,10 +87,109 @@ class ActivityLogTable extends ZfQueryBasedTable $action .= 'deployed'; } - return $this::tr([ + $columns = [ $this::td($this->makeLink($row))->setSeparator(' '), - $this::td($this->timeFormat->getTime($row->ts_change_time)) - ])->addAttributes(['class' => $action]); + ]; + if (! $this->hasObjectFilter) { + $columns[] = $this->makeRangeInfo($row->id); + } + $columns[] = $this::td($this->timeFormat->getTime($row->ts_change_time)); + + return $this::tr($columns)->addAttributes(['class' => $action]); + } + + /** + * Hint: cloned from parent class and modified + * @param int $timestamp + */ + protected function renderDayIfNew($timestamp) + { + $day = $this->getDateFormatter()->getFullDay($timestamp); + + if ($this->lastDay !== $day) { + $this->nextHeader()->add( + $this::th($day, [ + 'colspan' => $this->hasObjectFilter ? 2 : 3, + 'class' => 'table-header-day' + ]) + ); + + $this->lastDay = $day; + if ($this->currentRangeCell) { + if ($this->currentRange->first_related_activity <= $this->currentRow->id) { + $this->currentRangeCell->addAttributes(['class' => 'continuing']); + $this->continueRange = true; + } else { + $this->continueRange = false; + } + } + $this->currentRangeCell = null; + $this->currentRange = null; + $this->rangeRows = 0; + $this->nextBody(); + } + } + + protected function makeRangeInfo($id) + { + $range = $this->getRangeForId($id); + if ($range === null) { + if ($this->currentRangeCell) { + $this->currentRangeCell->getAttributes()->remove('class', 'continuing'); + } + $this->currentRange = null; + $this->currentRangeCell = null; + $this->rangeRows = 0; + return $this::td(); + } + + if ($range === $this->currentRange) { + $this->growCurrentRange(); + return null; + } + $this->startRange($range); + + return $this->currentRangeCell; + } + + protected function startRange($range) + { + $this->currentRangeCell = $this::td($this->renderRangeComment($range), [ + 'colspan' => $this->rangeRows = 1, + 'class' => 'comment-cell' + ]); + if ($this->continueRange) { + $this->currentRangeCell->addAttributes(['class' => 'continued']); + $this->continueRange = false; + } + $this->currentRange = $range; + } + + protected function renderRangeComment($range) + { + // The only purpose of this container is to avoid hovered rows from influencing + // the comments background color, as we're using the alpha channel to lighten it + // This can be replaced once we get theme-safe colors for such messages + return Html::tag('div', [ + 'class' => 'range-comment-container', + ], Link::create($this->continueRange ? '' : $range->remark, '#', null, ['class' => 'range-comment'])); + } + + protected function growCurrentRange() + { + $this->rangeRows++; + $this->currentRangeCell->setAttribute('rowspan', $this->rangeRows); + } + + protected function getRangeForId($id) + { + foreach ($this->ranges as $range) { + if ($id >= $range->first_related_activity && $id <= $range->last_related_activity) { + return $range; + } + } + + return null; } protected function makeLink($row) diff --git a/public/css/module.less b/public/css/module.less index 6836f9b3..1a3611b1 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -1297,6 +1297,9 @@ th.table-header-day { } table.activity-log { + td { + max-height: 2em; + } tr th:first-child { padding-left: 2em; } @@ -1304,6 +1307,7 @@ table.activity-log { tr td:last-child { text-align: right; white-space: nowrap; + width:10%; } tr td:first-child { @@ -1362,6 +1366,57 @@ table.activity-log { tr.undeployed td:first-child::before { color: @gray; } + + div.range-comment-container { + width: 100%; + position: absolute; + height: 100%; + background: @body-bg-color; + border-radius: 0.5em; + } + a.range-comment { + width: 100%; + height: 100%; + display: block; + border-radius: 0.5em; + padding: 0.2em 1em; + vertical-align: middle; + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + -ms-overflow-style: none; + overflow-y:auto; + overflow-x:hidden; + &:hover { + text-decoration: none; + } + background: fade(@color-warning-handled, 40%); + &:hover { + background: fade(@color-warning-handled, 60%); + } + } + td.comment-cell { + padding: 0; + min-width: 10em; + position: relative; + &.continuing div.range-comment-container { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + a.range-comment { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + &.continued div.range-comment-container { + border-top-left-radius: 0; + border-top-right-radius: 0; + a.range-comment { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + } } tr.branch_modified { @@ -1800,4 +1855,4 @@ table.table-basket-changes { text-align: left; min-width: 10em; } -} \ No newline at end of file +} diff --git a/schema/mysql-migrations/upgrade_178.sql b/schema/mysql-migrations/upgrade_178.sql new file mode 100644 index 00000000..589e6043 --- /dev/null +++ b/schema/mysql-migrations/upgrade_178.sql @@ -0,0 +1,20 @@ +CREATE TABLE director_activity_log_remark ( + first_related_activity BIGINT(20) UNSIGNED NOT NULL, + last_related_activity BIGINT(20) UNSIGNED NOT NULL, + remark TEXT NOT NULL, + PRIMARY KEY (first_related_activity, last_related_activity), + CONSTRAINT activity_log_remark_begin + FOREIGN KEY first_related_activity (first_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT activity_log_remark_end + FOREIGN KEY last_related_activity (last_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +INSERT INTO director_schema_migration + (schema_version, migration_time) + VALUES ('178', NOW()); diff --git a/schema/mysql.sql b/schema/mysql.sql index 12c4a6b3..142b91ca 100644 --- a/schema/mysql.sql +++ b/schema/mysql.sql @@ -49,6 +49,23 @@ CREATE TABLE director_activity_log ( INDEX checksum (checksum) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE director_activity_log_remark ( + first_related_activity BIGINT(20) UNSIGNED NOT NULL, + last_related_activity BIGINT(20) UNSIGNED NOT NULL, + remark TEXT NOT NULL, + PRIMARY KEY (first_related_activity, last_related_activity), + CONSTRAINT activity_log_remark_begin + FOREIGN KEY first_related_activity (first_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT activity_log_remark_end + FOREIGN KEY last_related_activity (last_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE director_basket ( uuid VARBINARY(16) NOT NULL, basket_name VARCHAR(64) NOT NULL, @@ -2397,4 +2414,4 @@ CREATE TABLE branched_icinga_dependency ( INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (176, NOW()); + VALUES (178, NOW()); diff --git a/schema/pgsql-migrations/upgrade_178.sql b/schema/pgsql-migrations/upgrade_178.sql new file mode 100644 index 00000000..8419384a --- /dev/null +++ b/schema/pgsql-migrations/upgrade_178.sql @@ -0,0 +1,23 @@ +CREATE TABLE director_activity_log_remark ( + first_related_activity bigint NOT NULL, + last_related_activity bigint NOT NULL, + remark TEXT NOT NULL, + PRIMARY KEY (first_related_activity, last_related_activity), + CONSTRAINT activity_log_remark_begin + FOREIGN KEY (first_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT activity_log_remark_end + FOREIGN KEY (last_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX first_related_activity ON director_activity_log_remark (first_related_activity); +CREATE INDEX last_related_activity ON director_activity_log_remark (last_related_activity); + +INSERT INTO director_schema_migration + (schema_version, migration_time) + VALUES (178, NOW()); diff --git a/schema/pgsql.sql b/schema/pgsql.sql index b73d84da..711390f9 100644 --- a/schema/pgsql.sql +++ b/schema/pgsql.sql @@ -97,6 +97,26 @@ CREATE INDEX activity_log_author ON director_activity_log (author); COMMENT ON COLUMN director_activity_log.old_properties IS 'Property hash, JSON'; COMMENT ON COLUMN director_activity_log.new_properties IS 'Property hash, JSON'; +CREATE TABLE director_activity_log_remark ( + first_related_activity bigint NOT NULL, + last_related_activity bigint NOT NULL, + remark TEXT NOT NULL, + PRIMARY KEY (first_related_activity, last_related_activity), + CONSTRAINT activity_log_remark_begin + FOREIGN KEY (first_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT activity_log_remark_end + FOREIGN KEY (last_related_activity) + REFERENCES director_activity_log (id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX first_related_activity ON director_activity_log_remark (first_related_activity); +CREATE INDEX last_related_activity ON director_activity_log_remark (last_related_activity); + CREATE TABLE director_basket ( uuid bytea CHECK(LENGTH(uuid) = 16) NOT NULL, @@ -2743,4 +2763,4 @@ CREATE INDEX branched_dependency_search_object_name ON branched_icinga_dependenc INSERT INTO director_schema_migration (schema_version, migration_time) - VALUES (176, NOW()); + VALUES (178, NOW());