diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aee25bc9e96c9d7d4152c0bd80eaeb8f5cc42011
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,14 @@
+stages:
+  - build
+
+build-segment-viewer:
+  stage: build
+  image: docker:latest
+  services:
+    - docker:dind
+  script:
+    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
+    - docker build --pull -t "$CI_REGISTRY_IMAGE/segment_viewer" .
+    - docker push "$CI_REGISTRY_IMAGE/segment_viewer"
+  only:
+    - master
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..f5d6b6c242942a52d44e597e792930ae529d69e3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+FROM ruby:2.5.3-slim-stretch
+
+LABEL Description="segment_viewer"
+
+RUN apt-get update \
+    && apt-get install -y build-essential \
+    && rm -rf /var/lib/apt/lists/*
+
+
+RUN useradd -ms /bin/bash segment_viewer
+USER segment_viewer
+WORKDIR /home/segment_viewer
+RUN chmod 755 /home/segment_viewer
+
+COPY Gemfile .
+COPY Gemfile.lock .
+
+RUN bundle install
+RUN mkdir /home/segment_viewer/.segment_viewer
+
+COPY segment_viewer.rb .
+
+ENTRYPOINT /home/segment_viewer/segment_viewer.rb
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000000000000000000000000000000000000..fa91cb98810175b5bba7d701e77f22f185198909
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,4 @@
+source ENV['GEM_SOURCE'] || 'https://rubygems.org'
+
+gem 'pmap', '~> 1.1', '>= 1.1.1'
+gem 'curses', '~> 1.2'
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000000000000000000000000000000000000..ce9749502bd62e60db7b74457e3951f062b0e918
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,15 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    curses (1.2.5)
+    pmap (1.1.1)
+
+PLATFORMS
+  ruby
+
+DEPENDENCIES
+  curses (~> 1.2)
+  pmap (~> 1.1, >= 1.1.1)
+
+BUNDLED WITH
+   1.16.2
diff --git a/README.md b/README.md
index bcb3144880ee3b0e3832956ad50cea0ac6afb87e..a6ccde2ee03d2945caa358dcc759e9192cbce6b1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,11 @@
 # segment_viewer
 
+DNS PTR resolver for segments defined in `networks.yml`.
+
+## Usage
+```
+docker run -it --rm \
+       -v <ABSOLUTE_PATH>/networks.yml:/home/segment_viewer/home/segment_viewer/.segment_viewer/.networks.yml \
+       -v segment_viewer:/home/segment_viewer/.segment_viewer:Z \
+       test
+```
diff --git a/networks.yml b/networks.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8be4fa21933d219ac749ac2bad9219e846e45d7b
--- /dev/null
+++ b/networks.yml
@@ -0,0 +1,47 @@
+---
+##############################################################################
+## Expected format:
+##
+##  firewall_groups:
+##    - name: String
+##      in:
+##        - network: CIDR
+##          policy: allow | deny
+##          protocols: [String, String, ...] # or `~` for any
+##          ports: [Integer, Integer, ...]   # or `~` for any
+##      out:
+##        - network: CIDR
+##          policy: allow | deny
+##          protocols: [String, String, ...] # or `~` for any
+##          ports: [Integer, Integer, ...]   # or `~` for any
+##
+##  trunks:
+##    - name: String
+##      vlans: [Integer, Integer, ...]
+##      env: [String, String, ...]
+##
+##  networks:
+##    - name: String
+##      network: CIDR
+##      label: String
+##      vlan: Integer
+##      gw: IPv4
+##      nat: IPv4
+##      dhcp: IPv4
+##      trunk: String
+##      firewall_group: String
+##      env: [String, String, ...]
+##
+##############################################################################
+
+networks:
+- name: Example
+  network: 10.0.1.0/28
+  label: Example-label
+  vlan: ~
+  gw: 10.0.1.1
+  nat: ~
+  dhcp: ~
+  trunk: ~
+  firewall_group: ~
+  env: Example-environment
diff --git a/segment_viewer.rb b/segment_viewer.rb
new file mode 100755
index 0000000000000000000000000000000000000000..a424ecadf6fda4ed81905687121bef1d3ae36cda
--- /dev/null
+++ b/segment_viewer.rb
@@ -0,0 +1,314 @@
+#!/usr/bin/env ruby
+require 'curses'
+require 'resolv'
+require 'ipaddr'
+require 'yaml'
+require 'pmap'
+require 'fileutils'
+
+NS='ns.muni.cz'.freeze
+CONF_DIR="#{ENV['HOME']}/.segment_viewer".freeze
+DNS_CACHE="#{CONF_DIR}/.cache.yml".freeze
+NETWORKS_FILE="#{CONF_DIR}/.networks.yml".freeze
+RESOLV_TIMEOUT=5.freeze
+THREAD_COUNT=128.freeze
+
+class ItemStore
+  def initialize(items, window_size ,first = 0)
+    @items = items
+    @item_selected_index = 0
+    @first_visible = first
+    @window_size = window_size > 0 ? window_size : 0
+  end
+
+  def move_up
+    @item_selected_index -= 1 unless @item_selected_index.zero?
+    scroll_up if @item_selected_index < @first_visible
+  end
+
+  def move_down
+    @item_selected_index += 1 unless @item_selected_index >= last_item_index
+    scroll_down if @item_selected_index > last_visible
+  end
+
+  def visible_items
+    @items[@first_visible, @window_size]
+  end
+
+  def selected_visible_item_index
+    raise "Selected item index (#{@item_selected_index}) under visible windows (first: #{@first_visible})" if @item_selected_index < @first_visible
+    raise "Selected item index (#{@item_selected_index}) above visible windows (last: #{last_visible})" if @item_selected_index > last_item_index
+    (@item_selected_index - @first_visible)
+  end
+
+  def selected_item
+    @items[@item_selected_index]
+  end
+
+  def resize(window_size)
+    raise "Window size is 0 :-(" if window_size < 0
+    @window_size = window_size
+    if @item_selected_index < @first_visible
+      @first_visible = @item_selected_index
+    elsif @item_selected_index > last_visible
+      @first_visible += 1 while @item_selected_index != last_visible
+    elsif last_visible > last_item_index
+      scroll_up until @first_visible.zero? or last_item_index == last_visible
+    end
+  end
+
+  private
+
+  def last_visible
+    @first_visible + @window_size - 1
+  end
+
+  def last_item_index
+    @items.size - 1
+  end
+
+  def scroll_up
+    @first_visible -= 1 unless @first_visible.zero?
+  end
+
+  def scroll_down
+    @first_visible += 1 unless @item_selected_index > last_item_index
+  end
+end
+
+
+class Panel
+  def initialize(side)
+    @height = Curses.lines
+    @width = Curses.cols / 2
+    @top = 0
+    @left = side == 'left' ? 0 : Curses.cols / 2
+    @win = Curses::Window.new(@height, @width, @top ,@left)
+    @win.box('|', '-')
+    @win.refresh
+    @active = false
+  end
+
+  def activate
+    @active = true
+  end
+
+  def deactivate
+    @active = false
+  end
+
+  def clear
+    @win.box('|', '-')
+    (@height - 2).times do |index|
+      @win.setpos(index + 1, 1)
+      @win.addstr ' ' * (@width - 2)
+    end
+  end
+
+  def draw_menu
+    clear
+    @item_store.visible_items.each_with_index do |item, index|
+      @win.setpos(index + 1, 2)
+      @win.attrset((@active and @item_store.selected_visible_item_index == index) ? Curses::A_STANDOUT : Curses::A_NORMAL)
+      @win.addstr item.ljust(@width - 4, ' ')
+    end
+    @win.attrset(Curses::A_NORMAL)
+    @win.refresh
+  end
+
+  def draw_message(msg)
+    @item_store = ItemStore.new(msg, @height - 2)
+    deactivate
+    draw_menu
+  end
+
+  def load_items(data)
+    @item_store = ItemStore.new(data, @height - 2)
+    draw_menu
+  end
+
+  def resize
+    @height = Curses.lines
+    @width = Curses.cols / 2
+    @item_store.resize (@height - 2)
+    draw_menu
+  end
+
+  def selected_item
+    @item_store.selected_item
+  end
+
+  def select_item
+    @win.keypad = true
+    ch = @win.getch
+    case ch
+      when Curses::KEY_UP then @item_store.move_up
+      when Curses::KEY_DOWN then @item_store.move_down
+    end
+    draw_menu
+    @win.keypad = false
+    return ch
+  end
+end
+
+class Resolver
+  def initialize(cache = DNS_CACHE)
+    @cache = cache
+    @resolver = Resolv::DNS.new(:nameserver => NS)
+    @resolver.timeouts = RESOLV_TIMEOUT
+    if File.file?(@cache)
+      @dns = YAML.load_file(@cache)
+    else
+      FileUtils.mkdir_p(CONF_DIR) unless File.directory?(CONF_DIR)
+      @dns = {}
+    end
+  end
+
+  def refresh_range(cidr)
+    resolve_range(cidr, true)
+  end
+
+  def resolve_range_l(cidr, refresh = false)
+    resolve_range(cidr, refresh)
+    @dns[cidr].map { |record| "#{record['ip'].to_s.ljust(16, ' ')} #{record['names'].join(' ')}" }
+  end
+
+  def resolve_range_s(cidr, refresh = false)
+    resolve_range(cidr, refresh)
+    dns_short = @dns[cidr].chunk { |record| record['names'] }.map do |el|
+      if el.first.none? && el.last.size > 2
+        [false, [el.last.first, { 'ip' => '    ...', 'names' => [] }, el.last.last]]
+      else
+        el
+      end
+    end.flat_map { |el| el.last }
+    dns_short.map { |record| "#{record['ip'].to_s.ljust(16, ' ')} #{record['names'].join(' ')}" }
+  end
+
+  private
+
+  def resolve_range(cidr, refresh = false)
+    if refresh or not @dns.key? cidr
+      @dns[cidr] = IPAddr.new(cidr).to_range.pmap(THREAD_COUNT) do |ip|
+        {
+          'ip' => ip.to_s,
+          'names' => @resolver.getnames(ip.to_s).map{ |dns| dns.to_s }
+        }
+      end
+      File.write(@cache, @dns.to_yaml)
+    end
+  end
+end
+
+def network_details (networks, name)
+  networks.select{|net| net['name'] == name }[0].map{|k,v| "#{k}: #{v}"}
+end
+
+def resolve_dns (panel, resolver, networks, name, force = false, long = false)
+    cidr = networks.select{|net| net['name'] == name }[0]['network']
+    panel.draw_message ["Resolving dns for #{cidr}"]
+    panel.activate
+    if long
+      panel.load_items resolver.resolve_range_l(cidr, force)
+    else
+      panel.load_items resolver.resolve_range_s(cidr, force)
+    end
+end
+
+def resolve_dns_all (panel, resolver, networks)
+    networks.each do |net|
+      cidr = net['network']
+      panel.draw_message ["Resolving dns for all networks. Current: #{cidr}"]
+      resolver.refresh_range cidr
+    end
+    panel.draw_message ["Resolving dns for all networks finished."]
+end
+
+def show_help
+  if not defined? @helppane
+    @helppane = Panel.new('right')
+    help = ["Segment_viewer help, YAY!"]
+    help << ""
+    help << "* use arrows to navigate/select segments"
+    help << "* use 'l' to show all IPs"
+    help << "* use 'r' to reload dns data for current or all networks"
+    help << "  app uses cache, so refreshing is encouraged"
+    help << "* use 'h' to show this help"
+    help << "* use 'q' to exit"
+    help << ""
+    help << "WARNING: private segments are resolved"
+    help << "         only inside MU network!"
+    @helppane.draw_message help
+  else
+    @helppane.resize
+  end
+end
+
+Curses.init_screen
+Curses.start_color
+Curses.noecho
+Curses.curs_set 0
+begin
+  lpane = Panel.new('left')
+  networks = YAML::load_file(File.join(__dir__, NETWORKS_FILE))['networks'].select { |net| !net['network'].nil? }
+  network_names = networks.map{ |net| net['name'] }.sort
+  menu =  networks.map { |net| net['env'] }.flatten.uniq
+  resolver = Resolver.new()
+  lpane.activate
+  lpane.load_items menu
+  rpane = Panel.new('right')
+  show_help
+  active_panel = lpane
+  mode = 'main_menu'
+  while true
+
+    key_pressed = active_panel.select_item
+    case key_pressed
+      when 'q' then exit
+      when 'h' then show_help
+      when Curses::KEY_RESIZE
+        lpane.resize
+        rpane.resize
+    end
+
+    if mode == 'main_menu'
+      case key_pressed
+        when Curses::KEY_RIGHT
+          network_subset = networks.select{ |net| net['env'].include? lpane.selected_item }
+          lpane.load_items network_subset.map{ |net| net['name'] }.sort
+          rpane.load_items network_details(networks, lpane.selected_item)
+          mode = 'segment'
+        when 'r' then resolve_dns_all(rpane, resolver, networks)
+
+      end
+    elsif mode == 'segment'
+      if active_panel == lpane
+        case key_pressed
+          when Curses::KEY_UP then rpane.load_items network_details(networks, lpane.selected_item)
+          when Curses::KEY_DOWN then rpane.load_items network_details(networks, lpane.selected_item)
+          when Curses::KEY_LEFT
+            lpane.load_items menu
+            mode = 'main_menu'
+          when Curses::KEY_RIGHT
+            active_panel = rpane
+            resolve_dns(rpane, resolver, networks, lpane.selected_item)
+          when 'r' then resolve_dns_all(rpane, resolver, networks)
+        end
+
+      elsif active_panel == rpane
+        case key_pressed
+        when 'r' then resolve_dns(rpane, resolver, networks, lpane.selected_item, true)
+        when 'l' then resolve_dns(rpane, resolver, networks, lpane.selected_item, false, true)
+        when Curses::KEY_LEFT
+          active_panel = lpane
+          rpane.deactivate
+          rpane.load_items network_details(networks, lpane.selected_item)
+        end
+      end
+    end
+  end
+rescue => ex
+  Curses.close_screen
+  puts ex
+  puts ex.backtrace
+end