archsight 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Dockerfile +4 -0
- data/docs/configuration.md +5 -0
- data/docs/diagram.md +69 -0
- data/docs/index.md.erb +1 -0
- data/docs/modeling.md +3 -1
- data/docs/pdf-export.md +118 -0
- data/lib/archsight/analysis/typst_renderer.rb +25 -78
- data/lib/archsight/cli.rb +39 -1
- data/lib/archsight/database.rb +4 -3
- data/lib/archsight/diagram/cli.rb +11 -1
- data/lib/archsight/diagram/errors.rb +1 -0
- data/lib/archsight/diagram/graph/attributes.rb +3 -1
- data/lib/archsight/diagram/graph/node.rb +18 -1
- data/lib/archsight/diagram/includes.rb +168 -0
- data/lib/archsight/diagram/layout/expander.rb +75 -3
- data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
- data/lib/archsight/diagram/parser/ast.rb +5 -0
- data/lib/archsight/diagram/parser/table_layout.rb +2 -0
- data/lib/archsight/diagram/parser.rb +58 -16
- data/lib/archsight/diagram.rb +20 -4
- data/lib/archsight/export/confluence/storage.rb +6 -1
- data/lib/archsight/helpers/diagram_blocks.rb +34 -19
- data/lib/archsight/helpers/diagram_includes.rb +67 -0
- data/lib/archsight/helpers.rb +1 -0
- data/lib/archsight/linter.rb +10 -2
- data/lib/archsight/markdown.rb +17 -1
- data/lib/archsight/pdf/cache.rb +41 -0
- data/lib/archsight/pdf/compiler.rb +69 -0
- data/lib/archsight/pdf/dependency_graph.rb +127 -0
- data/lib/archsight/pdf/document.rb +227 -0
- data/lib/archsight/pdf/embeds.rb +134 -0
- data/lib/archsight/pdf/images.rb +94 -0
- data/lib/archsight/pdf/markdown.rb +217 -0
- data/lib/archsight/pdf/remote_image.rb +122 -0
- data/lib/archsight/pdf/resource_sheet.rb +129 -0
- data/lib/archsight/pdf/sample_document.rb +67 -0
- data/lib/archsight/pdf/tables.rb +72 -0
- data/lib/archsight/pdf/theme.rb +125 -0
- data/lib/archsight/pdf/typst/archsight.typ +152 -0
- data/lib/archsight/pdf/typst.rb +45 -0
- data/lib/archsight/pdf.rb +30 -0
- data/lib/archsight/references.rb +4 -3
- data/lib/archsight/resources/application_component.rb +10 -0
- data/lib/archsight/resources/application_service.rb +9 -0
- data/lib/archsight/resources/config.rb +147 -0
- data/lib/archsight/resources/technology_service.rb +9 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/json_helpers.rb +3 -2
- data/lib/archsight/web/api/openapi/spec.yaml +239 -2
- data/lib/archsight/web/api/page_helpers.rb +1 -1
- data/lib/archsight/web/api/pdf_helpers.rb +46 -0
- data/lib/archsight/web/api/routes.rb +34 -3
- data/lib/archsight/web/application.rb +2 -1
- data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
- data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
- data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
- data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
- data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
- data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-BCKbW-Wp.css} +1 -1
- data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
- data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
- data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
- data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
- data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
- data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
- data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
- data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-BF4zUlWZ.js} +2 -2
- data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
- data/lib/archsight/web/public/vue.html +2 -2
- metadata +37 -17
- data/lib/archsight/web/public/vue/DocPage-C8Ysway-.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-pr2MR6_b.js +0 -35
- data/lib/archsight/web/public/vue/ErrorPage-FAkpW-hn.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-liQJzFyo.js +0 -1
- data/lib/archsight/web/public/vue/HomePage-DYpzSXG8.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
- data/lib/archsight/web/public/vue/KindList-CrgI4qDb.js +0 -1
- data/lib/archsight/web/public/vue/PageView-BIxT_t49.js +0 -1
- data/lib/archsight/web/public/vue/QueryError-ChdyV8B1.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-DdtaHY4k.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-BYRAcK_o.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-CgvRN6R2.js +0 -3
- data/lib/archsight/web/public/vue/index-BLSE5DSv.css +0 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typst"
|
|
4
|
+
|
|
5
|
+
module Archsight
|
|
6
|
+
module Pdf
|
|
7
|
+
# The facts of a resource that is not a page, as Typst markup: its properties (annotations) and its relations in
|
|
8
|
+
# both directions. Structure comes from alignment and light rules, not from boxes: a muted label column, rows
|
|
9
|
+
# divided by hairlines, related annotations kept together, and the resources of a relation flowing in columns
|
|
10
|
+
# instead of one long comma-separated line.
|
|
11
|
+
class ResourceSheet
|
|
12
|
+
SKIP = %w[architecture/description architecture/diagram architecture/tags].freeze
|
|
13
|
+
LONG = 400 # characters; longer single-line values still sit in the table, wrapped, up to MAX
|
|
14
|
+
MAX = 2000
|
|
15
|
+
RULE = "0.4pt + theme.muted.lighten(75%)" # as the rule under the rows of every other table
|
|
16
|
+
NAMES_IN_COLUMNS = 4 # from this many resources a relation lists them in two columns
|
|
17
|
+
|
|
18
|
+
# @param markdown [#call] `(String) -> Typst`, renders markdown the way the document does
|
|
19
|
+
def initialize(resource, markdown:)
|
|
20
|
+
@resource = resource
|
|
21
|
+
@markdown = markdown
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] the sections (heading and content) of the properties, none if there are none
|
|
25
|
+
def properties
|
|
26
|
+
short, long = annotations.partition { |key, value| table_value?(key, value) }
|
|
27
|
+
parts = []
|
|
28
|
+
parts << "#heading(level: 1)[#{Typst.str("Properties")}]\n\n#{property_table(short)}" unless short.empty?
|
|
29
|
+
long.each { |key, value| parts << long_value(key, value) }
|
|
30
|
+
parts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] the sections of the outgoing and the incoming relations
|
|
34
|
+
def relations
|
|
35
|
+
out = outgoing
|
|
36
|
+
incoming = self.incoming
|
|
37
|
+
return [] if out.empty? && incoming.empty?
|
|
38
|
+
|
|
39
|
+
parts = ["#heading(level: 1)[#{Typst.str("Relations")}]"]
|
|
40
|
+
parts << "#heading(level: 2)[#{Typst.str("Outgoing")}]\n\n#{relation_table(out)}" unless out.empty?
|
|
41
|
+
parts << "#heading(level: 2)[#{Typst.str("Incoming")}]\n\n#{caption("Resources that point to this one; the relation is read from their side.")}\n\n#{relation_table(incoming)}" unless incoming.empty?
|
|
42
|
+
parts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `dependsOn` -> "depends on"
|
|
46
|
+
def self.humanize(verb)
|
|
47
|
+
verb.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def klass = @resource.class
|
|
53
|
+
|
|
54
|
+
def annotations
|
|
55
|
+
@resource.annotations.reject { |key, value| SKIP.include?(key) || value.to_s.strip.empty? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Multi-line and markdown values are text, not table cells
|
|
59
|
+
def table_value?(key, value)
|
|
60
|
+
!value.to_s.include?("\n") && !%w[markdown multiline].include?(klass.annotation_format(key).to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def display(key, value)
|
|
64
|
+
text = value.to_s
|
|
65
|
+
text = text.split(",").map(&:strip).reject(&:empty?).join(", ") if klass.annotation_matching(key)&.list?
|
|
66
|
+
text.length > MAX ? "#{text[0, MAX]}…" : text
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Annotations of one namespace (`git/...`, `lifecycle/...`) stay together, separated from the next by a gap
|
|
70
|
+
def property_table(pairs)
|
|
71
|
+
grouped = pairs.group_by { |key, _| key.split("/").first }.values
|
|
72
|
+
cells = grouped.each_with_index.flat_map do |group, index|
|
|
73
|
+
gap = index.zero? ? [] : ["table.cell(colspan: 2, stroke: none, inset: 3pt)[]"]
|
|
74
|
+
gap + group.flat_map do |key, value|
|
|
75
|
+
["[#text(fill: theme.muted)[#{Typst.str(klass.annotation_title(key))}]]", "[#{Typst.str(display(key, value))}]"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
"#table(columns: (30%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def long_value(key, value)
|
|
82
|
+
body = klass.annotation_format(key).to_s == "markdown" ? @markdown.call(value.to_s) : multiline(value.to_s)
|
|
83
|
+
"#heading(level: 2)[#{Typst.str(klass.annotation_title(key))}]\n\n#{body}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def multiline(text)
|
|
87
|
+
text.lines.map { |line| Typst.str(line.chomp) }.join("#linebreak()")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# { verb => { kind => [instances] } }
|
|
91
|
+
def outgoing = @resource.relations_grouped
|
|
92
|
+
|
|
93
|
+
# incoming comes as { kind => { verb => [instances] } }, shown like the outgoing ones
|
|
94
|
+
def incoming
|
|
95
|
+
@resource.references_grouped.each_with_object({}) do |(kind, verbs), by_verb|
|
|
96
|
+
verbs.each { |verb, instances| (by_verb[verb] ||= {})[kind] = instances }
|
|
97
|
+
end.sort.to_h
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The relation (said once for all of its kinds), the kind, and the names of the resources in one or two columns
|
|
101
|
+
def relation_table(by_verb)
|
|
102
|
+
cells = by_verb.flat_map do |verb, kinds|
|
|
103
|
+
kinds.each_with_index.flat_map do |(kind, instances), index|
|
|
104
|
+
row = []
|
|
105
|
+
row << "table.cell(rowspan: #{kinds.size})[#text(weight: \"semibold\")[#{Typst.str(self.class.humanize(verb))}]]" if index.zero?
|
|
106
|
+
row << "[#pad(right: 8pt, text(fill: theme.muted)[#{Typst.str(breakable(kind))}])]"
|
|
107
|
+
row << "[#{names(instances)}]"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
"#table(columns: (17%, 24%, 1fr), stroke: (x, y) => (bottom: #{RULE}), inset: (x: 0pt, y: 5pt), #{cells.join(", ")})"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def names(instances)
|
|
114
|
+
list = instances.map(&:name).uniq.sort.map { |name| "[#{Typst.str(breakable(name))}]" }
|
|
115
|
+
columns = list.size >= NAMES_IN_COLUMNS ? "1fr, 1fr" : "1fr"
|
|
116
|
+
"#grid(columns: (#{columns},), column-gutter: 10pt, row-gutter: 4pt, #{list.join(", ")})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Names have no spaces: allow a line break after each `:` and between camel-case words (no visible change)
|
|
120
|
+
def breakable(name)
|
|
121
|
+
name.gsub(":", ":\u200B").gsub(/([a-z0-9])([A-Z])/, "\\1\u200B\\2")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def caption(text)
|
|
125
|
+
"#text(size: 8.5pt, fill: theme.muted)[#{Typst.str(text)}]"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
|
|
5
|
+
module Archsight
|
|
6
|
+
module Pdf
|
|
7
|
+
# A fixed sample document in the look of one pdf Config, to see what the settings do without exporting a real page:
|
|
8
|
+
# a cover page and a page with the elements a document is made of (headings, text, list, quote, table with
|
|
9
|
+
# status, code). The content is neutral on purpose; only the Config shapes the result. The "resource" of the document
|
|
10
|
+
# is the Config (an Archsight::Resources::Config of type pdf).
|
|
11
|
+
class SampleDocument < Document
|
|
12
|
+
PARTS = %w[cover page].freeze
|
|
13
|
+
TITLE = "Sample document"
|
|
14
|
+
BODY = <<~MD
|
|
15
|
+
## Summary
|
|
16
|
+
|
|
17
|
+
This page shows how headings, text, lists and tables look with the settings of the Config. It is a fixed sample,
|
|
18
|
+
not one of your documents, so every change of a colour, a font or the logo is visible at once.
|
|
19
|
+
|
|
20
|
+
> A quote stands out from the text around it and is set in the same typeface.
|
|
21
|
+
|
|
22
|
+
### What a document contains
|
|
23
|
+
|
|
24
|
+
- Text with **strong** and *emphasised* words, `inline code` and a [link](https://example.com)
|
|
25
|
+
- Lists, tables, diagrams and code, set in the fonts of the Config
|
|
26
|
+
- Status badges such as {status:green Active} and {status:yellow Planned}
|
|
27
|
+
|
|
28
|
+
## Components
|
|
29
|
+
|
|
30
|
+
| Component | Owner | Status |
|
|
31
|
+
|:----------|:------|:-------|
|
|
32
|
+
| Billing API | Payments | {status:green Active} |
|
|
33
|
+
| Export service | Data | {status:yellow Deprecated} |
|
|
34
|
+
| Legacy gateway | Platform | {status:red End of life} |
|
|
35
|
+
|
|
36
|
+
### Interface
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
def total(items)
|
|
40
|
+
items.sum(&:price)
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
MD
|
|
44
|
+
|
|
45
|
+
# @param part [String, nil] `cover` or `page` for just that page (a document of one page, so a viewer needs no
|
|
46
|
+
# navigation), else both
|
|
47
|
+
def initialize(database, config, part: nil, **)
|
|
48
|
+
@part = part if PARTS.include?(part)
|
|
49
|
+
super(database, config, **)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def filename = "#{["preview", super.delete_suffix(".pdf"), @part].compact.join("-")}.pdf"
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def theme_for(_template) = Theme.new(@resource, resources_dir: @resources_dir)
|
|
57
|
+
def title_for = TITLE
|
|
58
|
+
def tag_list = @part == "page" ? [] : %w[pdf:cover]
|
|
59
|
+
def body(images) = @part == "cover" ? "" : markdown(images).render(BODY)
|
|
60
|
+
def doc_id = "SAMPLE-001"
|
|
61
|
+
def version = "1.0"
|
|
62
|
+
def owner = "Alex Example"
|
|
63
|
+
def kind = "Sample"
|
|
64
|
+
def classification = @theme.classification
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typst"
|
|
4
|
+
require_relative "markdown"
|
|
5
|
+
require_relative "../view_table"
|
|
6
|
+
require_relative "../export/confluence/tables"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
module Pdf
|
|
10
|
+
# A ViewTable::Table (the result of a view, of requirements or of an analysis) as a Typst table. Views are live
|
|
11
|
+
# in Archsight; in the PDF they are the data of the moment of the export.
|
|
12
|
+
module Tables
|
|
13
|
+
# A column whose longest text is longer than this shares the width (`fr`), shorter ones take what they need
|
|
14
|
+
LONG = 30
|
|
15
|
+
SMALL_FONT_FROM = 4 # columns
|
|
16
|
+
Confluence = Export::Confluence::Tables
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param table [Archsight::ViewTable::Table]
|
|
21
|
+
# @param markdown [#call] Typst markup of a markdown text, for the cells of `as: :markdown`
|
|
22
|
+
# @param empty [String] shown instead of the table when there are no rows
|
|
23
|
+
# @return [String] Typst markup
|
|
24
|
+
def typst(table, markdown:, empty: "No results")
|
|
25
|
+
parts = []
|
|
26
|
+
parts << heading(table) unless table.title.to_s.empty?
|
|
27
|
+
parts << (table.rows.empty? ? Typst.note(empty, fill: "#f2f2f2") : grid(table, markdown))
|
|
28
|
+
parts << "#emph[#{Typst.str("#{table.cut} more #{table.cut == 1 ? "row" : "rows"} not shown, see Archsight")}]" if table.cut.positive?
|
|
29
|
+
parts.join("\n\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def heading(table)
|
|
33
|
+
count = "(#{table.total} #{table.total == 1 ? "item" : "items"})"
|
|
34
|
+
"#block(sticky: true)[#strong[#{Typst.str(table.title)}] #text(fill: luma(120))[#{Typst.str(count)}]]"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def grid(table, markdown)
|
|
38
|
+
head = table.columns.map { |c| "[#text(weight: \"semibold\")[#{Typst.str(c)}]]" }
|
|
39
|
+
cells = table.rows.flat_map { |row| row.map { |cell| "[#{cell(cell, markdown)}]" } }
|
|
40
|
+
out = "#table(columns: (#{widths(table).join(", ")},), table.header(#{head.join(", ")}), #{cells.join(", ")})"
|
|
41
|
+
table.columns.size >= SMALL_FONT_FROM ? "#[#set text(size: 8.5pt)\n#{out}]" : out
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `auto` for short columns, a share of the rest for long ones, so wide tables wrap instead of running off the page
|
|
45
|
+
def widths(table)
|
|
46
|
+
table.columns.each_index.map do |i|
|
|
47
|
+
longest = [table.columns[i].length, *table.rows.map { |row| row[i] ? text_of(row[i]).length : 0 }].max
|
|
48
|
+
longest > LONG ? "#{(longest / LONG.to_f).round.clamp(1, 4)}fr" : "auto"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def text_of(cell)
|
|
53
|
+
cell.resources.empty? ? cell.text.to_s : cell.resources.map(&:name).join(" ")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cell(cell, markdown)
|
|
57
|
+
return cell.resources.map { |resource| Typst.str(resource.name) }.join("#linebreak()") unless cell.resources.empty?
|
|
58
|
+
|
|
59
|
+
case cell.as
|
|
60
|
+
when :status then badge(cell.text, Confluence::STATUS_COLOURS)
|
|
61
|
+
when :priority then cell.text.to_s.empty? ? "" : badge(cell.text, Confluence::PRIORITY_COLOURS)
|
|
62
|
+
when :markdown then markdown.call(cell.text.to_s)
|
|
63
|
+
else cell.text.to_s.split("\n").map { |line| Typst.str(line) }.join("#linebreak()")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def badge(text, colours)
|
|
68
|
+
Typst.badge(text, Markdown::STATUS_FILL.fetch(colours.fetch(text, "grey"), "#e8e8e8"))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typst"
|
|
4
|
+
require_relative "../assets"
|
|
5
|
+
|
|
6
|
+
module Archsight
|
|
7
|
+
module Pdf
|
|
8
|
+
# The look of an exported document: what a Config resource of type pdf says, with a neutral built-in look for
|
|
9
|
+
# everything it leaves out (or for no template at all). Every value is checked here, so a template loaded
|
|
10
|
+
# from a file that was never linted cannot put anything but a colour, a font name or a length into the
|
|
11
|
+
# Typst source.
|
|
12
|
+
class Theme
|
|
13
|
+
DEFAULT_NAME = "default"
|
|
14
|
+
TYPE = "pdf" # the `config/type` of the Configs that describe the look
|
|
15
|
+
COLORS = {
|
|
16
|
+
primary: "#1f3a5f", accent: "#2f7fd1", text: "#1c1c1c", muted: "#6b7280",
|
|
17
|
+
ok: "#1f9d55", warn: "#d98e04", danger: "#c0392b"
|
|
18
|
+
}.freeze
|
|
19
|
+
FONTS = {
|
|
20
|
+
body: ["Liberation Sans", "DejaVu Sans", "Helvetica", "Arial"],
|
|
21
|
+
heading: ["Liberation Sans", "DejaVu Sans", "Helvetica", "Arial"],
|
|
22
|
+
mono: ["DejaVu Sans Mono", "Liberation Mono", "Menlo", "Courier New"]
|
|
23
|
+
}.freeze
|
|
24
|
+
PAPERS = %w[a4 a5 us-letter].freeze
|
|
25
|
+
LANGS = %w[en de].freeze
|
|
26
|
+
HEADER_PARTS = %w[id version date owner].freeze
|
|
27
|
+
DEFAULT_MARGIN = "16mm"
|
|
28
|
+
LOGO_TYPES = %w[.svg .png].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :name, :colors, :fonts, :footer_text, :classification, :header_meta, :lang, :paper, :margin,
|
|
31
|
+
:logo, :logo_light, :font_dir
|
|
32
|
+
|
|
33
|
+
# @param template [Archsight::Resources::Config, nil]
|
|
34
|
+
# @param resources_dir [String] logos and fonts are looked up below it
|
|
35
|
+
def initialize(template = nil, resources_dir: Archsight.resources_dir)
|
|
36
|
+
@name = template&.name
|
|
37
|
+
ann = template ? template.annotations : {}
|
|
38
|
+
@colors = COLORS.to_h { |key, default| [key, color(ann["pdf/color-#{key}"], default)] }
|
|
39
|
+
@fonts = FONTS.to_h { |key, default| [key, font_list(ann["pdf/font-#{key}"], default)] }
|
|
40
|
+
@footer_text = blank_to_nil(ann["pdf/footer-text"])
|
|
41
|
+
@classification = blank_to_nil(ann["pdf/classification"])
|
|
42
|
+
@header_meta = header_parts(ann["pdf/header-meta"])
|
|
43
|
+
@lang = LANGS.include?(ann["pdf/lang"]) ? ann["pdf/lang"] : "en"
|
|
44
|
+
@paper = PAPERS.include?(ann["pdf/paper"]) ? ann["pdf/paper"] : "a4"
|
|
45
|
+
@margin = ann["pdf/margin"].to_s.match?(Resources::Config::LENGTH) ? ann["pdf/margin"] : DEFAULT_MARGIN
|
|
46
|
+
@logo = logo_file(ann["pdf/logo"], resources_dir)
|
|
47
|
+
@logo_light = logo_file(ann["pdf/logo-light"], resources_dir) || @logo
|
|
48
|
+
@font_dir = font_directory(ann["pdf/font-dir"], resources_dir)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The Config a document asks for (`pdf:template:<name>`), else the one named `default`, else the built-in look. Only
|
|
52
|
+
# Configs of type pdf count.
|
|
53
|
+
# @param requested [String, nil] name as written in the tag, with or without the `Config:` and `PDF:` prefixes
|
|
54
|
+
def self.for(database, requested = nil)
|
|
55
|
+
wanted = (requested || DEFAULT_NAME).to_s.strip
|
|
56
|
+
configs = database.instances_by_kind("Config").values.select { |c| c.annotations["config/type"].to_s.casecmp?(TYPE) }
|
|
57
|
+
new(configs.find { |config| matches?(config.name, wanted) })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `brand`, `PDF:brand` and `Config:PDF:brand` all name the same Config
|
|
61
|
+
def self.matches?(name, wanted)
|
|
62
|
+
short(name).casecmp?(short(wanted))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.short(name)
|
|
66
|
+
name.to_s.strip.sub(/\AConfig:/i, "").sub(/\APDF:/i, "")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Typst source defining `theme`. `logo` and `logo_light` are the file names the logos were staged under.
|
|
70
|
+
def to_typst(logo: nil, logo_light: nil)
|
|
71
|
+
fields = colors.map { |key, hex| "#{key}: #{Typst.color(hex)}" }
|
|
72
|
+
fields += fonts.map { |key, list| "font-#{key}: (#{list.map { |f| Typst.lit(f) }.join(", ")},)" }
|
|
73
|
+
fields += [
|
|
74
|
+
"logo: #{logo ? Typst.lit(logo) : "none"}", "logo-light: #{logo_light ? Typst.lit(logo_light) : "none"}",
|
|
75
|
+
"footer-text: #{footer_text ? Typst.lit(footer_text) : "none"}",
|
|
76
|
+
"header-meta: (#{header_meta.map { |p| Typst.lit(p) }.join(", ")},)",
|
|
77
|
+
"lang: #{Typst.lit(lang)}", "paper: #{Typst.lit(paper)}", "margin: #{margin}"
|
|
78
|
+
]
|
|
79
|
+
"#let theme = (#{fields.join(", ")})\n"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def color(value, default)
|
|
85
|
+
value.to_s.strip.match?(Resources::Config::COLOR) ? value.to_s.strip : default
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def font_list(value, default)
|
|
89
|
+
name = value.to_s.strip
|
|
90
|
+
name.match?(Resources::Config::FONT_NAME) ? [name, *default] : default
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def header_parts(value)
|
|
94
|
+
return HEADER_PARTS if value.to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
parts = value.to_s.split(",").map(&:strip) & HEADER_PARTS
|
|
97
|
+
parts.empty? ? HEADER_PARTS : parts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def blank_to_nil(value)
|
|
101
|
+
text = value.to_s.strip
|
|
102
|
+
text.empty? ? nil : text
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [String, nil] absolute path of a logo inside the resources directory
|
|
106
|
+
def logo_file(path, resources_dir)
|
|
107
|
+
return nil if path.to_s.strip.empty?
|
|
108
|
+
|
|
109
|
+
file = Assets.file_for(path.to_s, resources_dir: resources_dir)
|
|
110
|
+
file if file && LOGO_TYPES.include?(File.extname(file).downcase)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def font_directory(path, resources_dir)
|
|
114
|
+
normalized = Assets.normalize(path.to_s)
|
|
115
|
+
return nil unless normalized
|
|
116
|
+
|
|
117
|
+
root = File.realpath(resources_dir)
|
|
118
|
+
dir = File.realpath(File.join(root, normalized))
|
|
119
|
+
dir if dir.start_with?(root + File::SEPARATOR) && File.directory?(dir)
|
|
120
|
+
rescue SystemCallError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Page shell of the Archsight PDF export. Generic: every colour, font, logo and text comes from `theme`
|
|
2
|
+
// (see Archsight::Pdf::Theme), every per-document value is an argument.
|
|
3
|
+
|
|
4
|
+
#let labels = (
|
|
5
|
+
en: (page: "Page", of: "of", contents: "Contents", draft: "DRAFT", doc-id: "Document ID", version: "Version", date: "Date", owner: "Owner", classification: "Classification"),
|
|
6
|
+
de: (page: "Seite", of: "von", contents: "Inhalt", draft: "ENTWURF", doc-id: "Dokument-ID", version: "Version", date: "Datum", owner: "Eigentümer", classification: "Einstufung"),
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Identifiers have no spaces: allow a line break after `-`, `:` and `/` and between camel-case words (no visible change)
|
|
10
|
+
#let breakable(s) = if type(s) != str { s } else {
|
|
11
|
+
s.replace(regex("([:/-])"), m => m.text + "\u{200B}").replace(regex("([a-z0-9])([A-Z])"), m => m.captures.at(0) + "\u{200B}" + m.captures.at(1))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// An image at its natural size, shrunk to fit the text width and `max-height` of the page, centred
|
|
15
|
+
#let fit-image(path, max-height: 0.5) = layout(size => context {
|
|
16
|
+
let natural = measure(image(path))
|
|
17
|
+
let limit = page.height * max-height
|
|
18
|
+
let w = calc.min(natural.width, size.width)
|
|
19
|
+
let h = w * (natural.height / natural.width)
|
|
20
|
+
if h > limit {
|
|
21
|
+
h = limit
|
|
22
|
+
w = h * (natural.width / natural.height)
|
|
23
|
+
}
|
|
24
|
+
align(center, image(path, width: w, height: h))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// The meta line of the header: the document id in the mono font, the parts separated by a light bar
|
|
28
|
+
#let _meta-line(theme, id, version, date, owner) = {
|
|
29
|
+
let t = labels.at(theme.lang)
|
|
30
|
+
let parts = ()
|
|
31
|
+
if "id" in theme.header-meta and id != none { parts.push(text(font: theme.font-mono, breakable(id))) }
|
|
32
|
+
if "version" in theme.header-meta and version != none { parts.push(box[#t.version #version]) }
|
|
33
|
+
if "date" in theme.header-meta and date != none { parts.push(box[#date]) }
|
|
34
|
+
if "owner" in theme.header-meta and owner != none { parts.push(box[#owner]) }
|
|
35
|
+
parts.join(h(4pt) + text(fill: theme.muted.lighten(60%), "|") + h(4pt))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The document shell: page layout, header, footer, headings, watermark.
|
|
39
|
+
#let doc(
|
|
40
|
+
theme: none,
|
|
41
|
+
title: "",
|
|
42
|
+
doc-id: none,
|
|
43
|
+
version: none,
|
|
44
|
+
date: none,
|
|
45
|
+
owner: none,
|
|
46
|
+
classification: none,
|
|
47
|
+
draft: false,
|
|
48
|
+
landscape: false,
|
|
49
|
+
body,
|
|
50
|
+
) = {
|
|
51
|
+
let t = labels.at(theme.lang)
|
|
52
|
+
set document(title: title)
|
|
53
|
+
set page(
|
|
54
|
+
paper: theme.paper,
|
|
55
|
+
flipped: landscape,
|
|
56
|
+
margin: (x: theme.margin, top: 22mm, bottom: 16mm),
|
|
57
|
+
header-ascent: 30%,
|
|
58
|
+
footer-descent: 35%,
|
|
59
|
+
header: grid(
|
|
60
|
+
columns: (1fr, auto),
|
|
61
|
+
align: (left + bottom, right + bottom),
|
|
62
|
+
text(size: 7pt, fill: theme.muted.lighten(30%), _meta-line(theme, doc-id, version, date, owner)),
|
|
63
|
+
if theme.logo != none { image(theme.logo, height: 6.5mm) },
|
|
64
|
+
),
|
|
65
|
+
footer: {
|
|
66
|
+
set text(size: 7pt, fill: theme.muted)
|
|
67
|
+
line(length: 100%, stroke: 0.5pt + theme.muted.lighten(70%))
|
|
68
|
+
v(-2pt)
|
|
69
|
+
grid(
|
|
70
|
+
columns: (1fr, auto, 1fr),
|
|
71
|
+
column-gutter: 8pt,
|
|
72
|
+
align: (left + horizon, center + horizon, right + horizon),
|
|
73
|
+
if classification != none { text(weight: "bold", tracking: 0.06em, fill: theme.danger, classification) },
|
|
74
|
+
if theme.footer-text != none { text(style: "italic", theme.footer-text) },
|
|
75
|
+
context [#t.page #counter(page).display() #t.of #counter(page).final().first()],
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
background: if draft {
|
|
79
|
+
rotate(-45deg, text(size: 110pt, weight: "bold", fill: theme.muted.transparentize(85%), t.draft))
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
set text(font: theme.font-body, size: 10pt, fill: theme.text, lang: theme.lang)
|
|
83
|
+
set par(justify: false, leading: 0.62em)
|
|
84
|
+
show raw: set text(font: theme.font-mono, size: 8.5pt)
|
|
85
|
+
show raw.where(block: true): it => block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%, it)
|
|
86
|
+
show heading: set text(font: theme.font-heading, fill: theme.primary)
|
|
87
|
+
show heading.where(level: 1): set text(size: 16pt)
|
|
88
|
+
show heading.where(level: 2): set text(size: 13pt)
|
|
89
|
+
show heading.where(level: 3): set text(size: 11pt)
|
|
90
|
+
show link: set text(fill: theme.accent)
|
|
91
|
+
// Tables have horizontal rules only: a stronger one under the header, a light one under every other row, no frame and
|
|
92
|
+
// no vertical lines; the first column is flush with the text. Tables that pass their own stroke keep it.
|
|
93
|
+
set table(
|
|
94
|
+
stroke: (x, y) => if y == 0 { (bottom: 0.7pt + theme.muted.lighten(35%)) } else { (bottom: 0.4pt + theme.muted.lighten(75%)) },
|
|
95
|
+
inset: (x, y) => (left: if x == 0 { 0pt } else { 6pt }, right: 6pt, y: 6pt),
|
|
96
|
+
)
|
|
97
|
+
set table.cell(breakable: false)
|
|
98
|
+
show table.header: set text(weight: "semibold")
|
|
99
|
+
body
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Title with the kind at its right, a hairline directly below, then an optional intro
|
|
103
|
+
#let title-block(theme: none, title: "", kind: none, intro: none) = {
|
|
104
|
+
grid(
|
|
105
|
+
columns: (1fr, auto),
|
|
106
|
+
align: (left + bottom, right + bottom),
|
|
107
|
+
text(font: theme.font-heading, size: 22pt, weight: "bold", fill: theme.primary, breakable(title)),
|
|
108
|
+
if kind != none { text(size: 8pt, fill: theme.muted, kind) },
|
|
109
|
+
)
|
|
110
|
+
v(-4pt)
|
|
111
|
+
line(length: 100%, stroke: 0.5pt + theme.muted.lighten(70%))
|
|
112
|
+
if intro != none { block(above: 8pt, below: 6pt, text(size: 9pt, fill: theme.muted, intro)) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A cover page without header and footer: a band with the logo at its bottom left, kind, title and a short rule, the
|
|
116
|
+
// document meta at the bottom left of the page. The pages after it are numbered from 1.
|
|
117
|
+
#let cover-page(theme: none, title: "", kind: none, doc-id: none, version: none, date: none, owner: none, classification: none) = {
|
|
118
|
+
let t = labels.at(theme.lang)
|
|
119
|
+
page(header: none, footer: none, margin: 0mm, background: none)[
|
|
120
|
+
#block(width: 100%, height: 52mm, fill: theme.primary, inset: (x: theme.margin, bottom: 12mm), align(left + bottom, if theme.logo-light != none { image(theme.logo-light, height: 9mm) }))
|
|
121
|
+
#block(width: 100%, inset: (x: theme.margin, top: 34mm), {
|
|
122
|
+
if kind != none {
|
|
123
|
+
text(size: 7.5pt, weight: 600, tracking: 0.04em, fill: theme.accent, upper(kind))
|
|
124
|
+
v(2pt)
|
|
125
|
+
}
|
|
126
|
+
par(leading: 0.4em, text(font: theme.font-heading, size: 28pt, weight: "bold", fill: theme.primary, breakable(title)))
|
|
127
|
+
v(10pt)
|
|
128
|
+
line(length: 36mm, stroke: 2pt + theme.accent)
|
|
129
|
+
})
|
|
130
|
+
#let meta = (
|
|
131
|
+
(t.doc-id, if doc-id != none { text(font: theme.font-mono, breakable(doc-id)) }),
|
|
132
|
+
(t.version, version),
|
|
133
|
+
(t.date, date),
|
|
134
|
+
(t.owner, owner),
|
|
135
|
+
(t.classification, if classification != none { text(weight: "bold", fill: theme.danger, classification) }),
|
|
136
|
+
).filter(m => m.at(1) != none)
|
|
137
|
+
#place(bottom + left, dx: theme.margin, dy: -20mm, grid(
|
|
138
|
+
columns: 2,
|
|
139
|
+
column-gutter: 14pt,
|
|
140
|
+
row-gutter: 7pt,
|
|
141
|
+
..meta.map(((k, v)) => (text(size: 8pt, fill: theme.muted, k), text(size: 9pt, v))).flatten(),
|
|
142
|
+
))
|
|
143
|
+
// the pages after the cover count from 1 (set inside the cover, so that nothing after it forces another page)
|
|
144
|
+
#counter(page).update(0)
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#let toc-page(theme: none, depth: 3) = {
|
|
149
|
+
let t = labels.at(theme.lang)
|
|
150
|
+
outline(title: t.contents, depth: depth)
|
|
151
|
+
pagebreak()
|
|
152
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archsight
|
|
4
|
+
module Pdf
|
|
5
|
+
# Building blocks for Typst source (https://typst.app). All text is emitted as Typst string literals
|
|
6
|
+
# (`#"..."`) and structure as function calls (`#strong[...]`), so no content can be mistaken for markup.
|
|
7
|
+
module Typst
|
|
8
|
+
LANGUAGE = /\A[A-Za-z0-9+#-]+\z/
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# A Typst string literal, for use as a function argument
|
|
13
|
+
def lit(text)
|
|
14
|
+
escaped = text.to_s.delete("\r").gsub(/[\\"]/) { "\\#{Regexp.last_match(0)}" }.gsub("\n", "\\n")
|
|
15
|
+
"\"#{escaped}\""
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A Typst string literal as markup, for use inside content
|
|
19
|
+
def str(text)
|
|
20
|
+
"##{lit(text)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A block of preformatted text
|
|
24
|
+
def raw(source, lang = nil)
|
|
25
|
+
lang_arg = lang.to_s.match?(LANGUAGE) ? ", lang: #{lit(lang)}" : ""
|
|
26
|
+
"#raw(#{lit(source.to_s.chomp)}, block: true#{lang_arg})"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A short text on a coloured background (a status)
|
|
30
|
+
def badge(text, hex)
|
|
31
|
+
"#box(fill: #{color(hex)}, inset: (x: 4pt, y: 1pt), radius: 2pt)[#{str(text)}]"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A framed message that stands where something could not be shown
|
|
35
|
+
def note(text, fill: "#fff3cc")
|
|
36
|
+
"#block(fill: #{color(fill)}, inset: 8pt, radius: 3pt, width: 100%)[#{str(text)}]"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A Typst colour from `#rrggbb` / `#rgb`
|
|
40
|
+
def color(hex)
|
|
41
|
+
"rgb(#{lit(hex)})"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "pdf/compiler"
|
|
6
|
+
require_relative "pdf/document"
|
|
7
|
+
|
|
8
|
+
module Archsight
|
|
9
|
+
# PDF export of pages and resources, laid out by Typst (https://typst.app, installed as the `typst` command).
|
|
10
|
+
# See Archsight::Pdf::Document for what steers it and Archsight::Resources::Config (type pdf) for the look.
|
|
11
|
+
module Pdf
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param resource [Archsight::Resources::Base] a page or any other resource
|
|
15
|
+
# @return [Archsight::Pdf::Document]
|
|
16
|
+
def document(database, resource, compiler: Compiler.new, **)
|
|
17
|
+
Document.new(database, resource, pdf_images: compiler.pdf_images?, **)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The PDF of a prepared document
|
|
21
|
+
# @return [String]
|
|
22
|
+
# @raise [Unavailable, Error]
|
|
23
|
+
def compile(document, compiler: Compiler.new)
|
|
24
|
+
Dir.mktmpdir("archsight-pdf") do |dir|
|
|
25
|
+
document.files.each { |name, content| File.binwrite(File.join(dir, name), content) }
|
|
26
|
+
compiler.compile(dir, font_paths: document.font_paths)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/archsight/references.rb
CHANGED
|
@@ -26,9 +26,10 @@ module Archsight
|
|
|
26
26
|
# Adds the derived relations to every resource of the database.
|
|
27
27
|
def derive!(database)
|
|
28
28
|
finder = Finder.new(database)
|
|
29
|
+
includes = Helpers::DiagramIncludes.for(database)
|
|
29
30
|
database.instances.values.flat_map(&:values).each do |inst|
|
|
30
31
|
text_sources(inst).each { |markdown| mentions(markdown, finder).each { |target| link(inst, :mentions, target) } }
|
|
31
|
-
diagram_sources(inst, database).each { |source| depictions(source, finder).each { |target| link(inst, :depicts, target) } }
|
|
32
|
+
diagram_sources(inst, database).each { |source| depictions(source, finder, includes).each { |target| link(inst, :depicts, target) } }
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -44,8 +45,8 @@ module Archsight
|
|
|
44
45
|
# The resources the nodes of a diagram source name with `resource "..."`; a source that is not a valid diagram
|
|
45
46
|
# names none.
|
|
46
47
|
# @return [Array<Base>]
|
|
47
|
-
def depictions(source, finder)
|
|
48
|
-
Diagram.resource_references(source).filter_map { |reference| finder.reference(reference) }
|
|
48
|
+
def depictions(source, finder, includes = nil)
|
|
49
|
+
Diagram.resource_references(source, includes: includes).filter_map { |reference| finder.reference(reference) }
|
|
49
50
|
rescue Diagram::Error
|
|
50
51
|
[]
|
|
51
52
|
end
|
|
@@ -55,6 +55,13 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
|
|
|
55
55
|
works in queries like a tag but is not a sidebar filter, as there are too many distinct values. Deployable forms (container image, chart, package) are `TechnologyArtifact`s, not
|
|
56
56
|
component types.
|
|
57
57
|
|
|
58
|
+
## Teams
|
|
59
|
+
|
|
60
|
+
A component is `maintainedBy` the team (`BusinessActor`) that builds and changes it and `operatedBy` the team
|
|
61
|
+
(`BusinessActor` or `BusinessRole`) that runs it in production. Build and run are often different teams, which is
|
|
62
|
+
the point of writing both. Both are optional; without a direct `maintainedBy`, the build team is the one that
|
|
63
|
+
maintains the component's `TechnologyArtifact`s.
|
|
64
|
+
|
|
58
65
|
## Where it runs
|
|
59
66
|
|
|
60
67
|
A component is `servedBy` the `TechnologyService` it is deployed on (a product's cluster, a managed platform) and
|
|
@@ -426,6 +433,9 @@ class Archsight::Resources::ApplicationComponent < Archsight::Resources::Base
|
|
|
426
433
|
relation :servedBy, :technologyComponents, :TechnologySystemSoftware
|
|
427
434
|
relation :servedBy, :technologyServices, :TechnologyService
|
|
428
435
|
relation :provisions, :technologyServices, :TechnologyService
|
|
436
|
+
relation :maintainedBy, :businessActors, :BusinessActor
|
|
437
|
+
relation :operatedBy, :businessActors, :BusinessActor
|
|
438
|
+
relation :operatedBy, :businessRoles, :BusinessRole
|
|
429
439
|
relation :exposes, :applicationInterfaces, :ApplicationInterface
|
|
430
440
|
relation :dependsOn, :applicationInterfaces, :ApplicationInterface
|
|
431
441
|
relation :dependsOn, :applicationComponents, :ApplicationComponent
|