매뉴얼

config.lua 한 줄부터 C 라이브러리까지

이 매뉴얼에 나오는 코드는 전부 실제로 서버에서 돌고 있는 두 데모의 소스 그대로입니다 — 문서만 따로 쓴 게 아니라, 데모 자체가 매뉴얼입니다. manual-1은 "config.lua 선언만으로 되는 것", manual-2는 "Lua/C로 직접 프로그래밍해야 나오는 것"을 난이도 순으로 보여줍니다. 왼쪽 목차에서 원하는 항목으로 바로 이동할 수 있고, 코드마다 어떤 필드가 무슨 타입이고 필수인지를 표로, 그 코드가 실제로 어떤 화면을 만드는지를 라이브 데모를 직접 캡처한 스크린샷으로 같이 붙여뒀습니다.

시작 경로

지금 하려는 일부터 고르세요

전체 문서를 위에서부터 읽을 필요가 없습니다. 필요한 작업을 선택하면 해당 단계로 이동합니다.

개요

view= / file= / Ctrl+O / Ctrl+F

이 네 가지만 알면 매뉴얼 전체를 읽을 준비가 끝납니다.

view=

이미 있는 화면(시스템/프로세스/로그/큐/디스크/네트워크/SHM 구조체/대시보드)을 그대로 켭니다. Lua 파일이 필요 없습니다.

file=

Lua 파일을 경유해서 같은 C 함수를 부르거나(재구현 아님), Lua로 직접 계산·판단 로직을 짭니다.

Ctrl+O (source_key)

지금 보는 화면을 만든 코드를 그대로 팝업으로 보여줍니다. SSH 터미널은 물론, 웹 브라우저에서도 F1/Ctrl+O 둘 다로 동일하게 동작합니다(웹은 source_key 설정값과 무관하게 이 두 키를 고정으로 지원). 웹에서 안 눌리면 한글(IME) 입력이 켜져 있는지 확인하세요 — 꺼야 정상 동작합니다.

Ctrl+F (filter_key)

조건 필터 입력 팝업을 엽니다. Enter는 이번 세션에만, Ctrl+S는 config.lua에 그 줄 그대로 영구 저장합니다. SSH TUI 전용 — 웹 대시보드는 이 팝업 대신 클라이언트 쪽 검색/정렬 UI를 씁니다.

원래 이름은 F1/F2 — config.lua에서 키를 지정하는 모든 곳(hotkey, config_editor_key 등)은 "F1"~"F12" 또는 "Ctrl+E" 표기를 씁니다. 이 매뉴얼이 Ctrl+O/ Ctrl+F를 기본값으로 쓰는 이유는 macOS 등에서 F1/F2가 OS 단축키에 가로채이는 경우가 많아서입니다 — Ctrl 조합은 그런 충돌이 없습니다.
theme — config.lua 최상위에 theme = "ocean"처럼 적으면 themes.lua에 정의된 배색(정상/경고/위험 색 등)으로 전체 화면이 바뀝니다. 생략하면 내장 기본 배색 그대로. refresh_interval_ms(기본 500)로 화면 갱신 주기도 조절할 수 있습니다 — 둘 다 SSH·웹 어느 쪽으로 봐도 동일하게 적용됩니다.

직접 눌러보고 싶다면 실제 라이브 인스턴스가 있습니다 — 홈페이지 "직접 체험하기" 섹션에서 SSH 없이 바로 확인할 수 있고, 다운로드 패키지를 받았다면 아래처럼 manual-1/· manual-2/가 그대로 들어있어 직접 실행해볼 수 있습니다.

manual-1 / manual-2 직접 실행하기

압축을 풀면 생기는 tapas-eye/ 디렉터리 루트에서 아래 명령을 그대로 실행하면 됩니다 — 두 매뉴얼 모두 --config만 바꿔서 같은 바이너리로 띄웁니다.

실행명령
manual-1 (SSH TUI) ./bin/tapas-eye --config manual-1/config.lua
manual-1 (웹 대시보드) ./bin/tapas-eye --config manual-1/config.lua --web-port 8080
manual-2 (SSH TUI) ./bin/tapas-eye --config manual-2/config.lua
manual-2 (웹 대시보드) ./bin/tapas-eye --config manual-2/config.lua --web-port 8081

--web-port를 붙이면 브라우저에서 http://<서버주소>:8080으로 접속합니다. tty가 필요한 TUI 앱이라 tmux/screen 세션 안에서 실행해야 한다는 점 등 실행 환경 주의사항은 바로 아래 콜아웃 참고.

manual-2는 위 명령만으로 6단계 전부 바로 동작합니다. manual-1은 대부분 그대로 동작하지만, "SHM 구조체"·"전문별 처리 건수"·"다중 SHM 조인"· "SHM 값 편집" 네 항목만 예시 데이터로 SHM을 한 번 채워야 값이 보입니다(재부팅 전까지 유지 — 매번 반복할 필요 없음). 소스는 examples/에 있고 컴파일 후 실행하면 됩니다:

# SHM 구조체 / 전문별 처리 건수 / 다중 SHM 조인 / SHM 값 편집 — 각 1회만 실행해두면 됨
gcc -o /tmp/demo_koscom examples/demo_koscom.c && /tmp/demo_koscom
gcc -o /tmp/demo_msg_stats examples/demo_msg_stats.c && /tmp/demo_msg_stats
gcc -o /tmp/demo_msg_join examples/demo_msg_join.c && /tmp/demo_msg_join
gcc -o /tmp/demo_loglevel_ctrl examples/demo_loglevel_ctrl.c && /tmp/demo_loglevel_ctrl

# 메시지 큐 — POSIX MQ 두 개를 채움 (마찬가지로 1회만 실행)
gcc -o /tmp/demo_queue examples/demo_queue.c -lrt && /tmp/demo_queue

정리하고 싶으면 ipcrm -M 0x1234(SHM 구조체) · ipcrm -M 0x1300(전문별 처리 건수) · ipcrm -M 0x1301 ; ipcrm -M 0x1302 ; ipcrm -M 0x1303(다중 SHM 조인) · ipcrm -M 0x1350(SHM 값 편집) · rm -f /dev/mqueue/tapas_demo_orders /dev/mqueue/tapas_demo_alerts(메시지 큐)로 지울 수 있지만, 지우기 전까지는 화면에 그대로 남아있는 게 정상 동작이니 서두를 필요는 없습니다.

웹 대시보드 실행 환경 주의
  • --web-port는 모든 인터페이스(0.0.0.0)에 바인드됩니다 — 다른 머신에서 접속하려면 방화벽/보안그룹에서 해당 포트를 열어야 합니다.
  • tapas-eye는 TUI 앱이라 stdin에 tty가 필요합니다. nohup ... &처럼 그냥 백그라운드로 띄우면 즉시 종료되므로, tmux/screen 세션 안에서 실행하세요(웹 대시보드만 쓸 목적이어도 동일).
  • 인증 기능이 없습니다(내부망 전용 가정). 외부 공개망에 직접 노출하지 말고 SSH 터널·VPN·리버스 프록시 뒤에 두세요.
  • 브라우저(클라이언트) 동시 접속은 최대 16개까지 지원합니다 — 초과분은 별도 오류 메시지 없이 연결이 거부됩니다.

manual-1 · 1

config.lua만으로 되는 화면들

이 메뉴("기본기능")의 항목은 전부 view= 한 줄이면 됩니다 — Lua 파일이 단 하나도 없습니다. C 쪽에 이미 구현된 view 엔진(system/process/log/queue/ disk/net 6종 + SHM 구조체 파서 1종)을 config.lua의 menus → items 배열에 테이블 하나만 추가해서 "켜는" 방식이라, 재빌드는 물론 문법 오류만 없으면 재시작조차 필요 없습니다 — 게이트웨이 모드에서는 파일 저장 즉시(최대 1초) 새 인스턴스로 반영됩니다.

실행 화면 · view = "system" 한 줄의 결과
시스템 상태 화면 — CPU/MEM 게이지, 메모리, 로드 애버리지, 가동 시간

나머지 5개(프로세스/로그/큐/디스크/네트워크)도 문법은 똑같습니다 — 아래 표에서 view id와 화면별 옵션만 바꾸면 됩니다.

화면config.lua비고
시스템 상태 { label = "시스템 상태", view = "system" } CPU·메모리·로드 애버리지·업타임. /proc/stat, /proc/meminfo 직접 읽기
프로세스 목록 { label = "프로세스 목록", view = "process", filter = "", gauge = "cpu,mem" } gauge를 지정한 컬럼만 막대(bar)로 표시
로그 { label = "로그", view = "log" } inotify 기반 실시간 tail, 경로는 config.lua 최상위 log_path
메시지 큐 { label = "메시지 큐", view = "queue", filter = "" } POSIX MQ(/dev/mqueue)를 스캔해 큐별 메시지 수·용량 조회
디스크 용량 { label = "디스크 용량", view = "disk", filter = "" } 마운트별 총량/사용량/여유공간, statvfs()
네트워크 트래픽 { label = "네트워크 트래픽", view = "net", filter = "" } 인터페이스별 rx/tx, /proc/net/dev

SHM 구조체 — 필드 160개짜리 C 구조체를 그대로 표로

나머지 두 항목은 SHM(공유메모리)에 C 구조체 배열을 그대로 써 둔 프로그램을 조회 프로그램 없이 바로 표로 보여주는, tapas-eye의 핵심 기능입니다. 예시로 쓴 KOSCOM_STOCK_A0은 실제 코스콤 시세 구조체 형태로, 필드가 160개가 넘습니다 — 기본(가로: 행=레코드, 열=필드) 표로 그리면 화면 폭을 한참 넘기기 때문에, 아래처럼 orientation = "vertical"로 행/열을 뒤집어서 필드가 아무리 많아도 가로로 안 넘치게 합니다.

SHM 구조체 (config)

필드 160개가 넘는 실제 시세 구조체(KOSCOM_STOCK_A0)를 헤더 파일 그대로 파싱합니다.

{
  label   = "SHM 구조체 (config)",
  view    = "manual_shmstruct_cfg",
  shm_key = 0x1234,
  header  = "examples/koscom_stock.h",
  struct  = "KOSCOM_STOCK_A0",
  start_index = 3,
  -- show 생략 = 전체 필드 표시. 필드가 160개+라 기본(가로: 행=레코드,
  -- 열=필드) 표로는 폭을 훨씬 넘긴다 — orientation="vertical"로
  -- 행=필드/열=레코드로 뒤집어서 필드가 아무리 많아도 안 넘치게 한다.
  orientation   = "vertical",
  vertical_rows = 3,  -- ←/→로 나란히 볼 레코드 수. PgUp/PgDn은 필드 스크롤
  filter = "",
}
실행 화면 · orientation="vertical"
SHM 구조체 화면 — 필드가 행, 레코드(#3/#4/#5)가 열로 표시됨
필드타입필수설명
shm_keynumber필수shmget()에 쓰는 SHM 키. 16진수(0x1234) 표기 그대로 써도 됩니다. file_path를 대신 쓰면 SHM 대신 일반 파일 경로에서 같은 형식의 레코드 배열을 읽습니다(다른 프로세스가 주기적으로 덤프하는 파일 등) — 헤더/구조체 파싱은 완전히 동일, 바이트 소스만 다릅니다.
headerstring필수구조체가 정의된 C 헤더 파일 경로. 런타임에 이 파일을 직접 파싱해서 필드 목록/타입/오프셋을 얻습니다 — 재컴파일 불필요.
structstring필수헤더 안에서 파싱할 구조체(또는 배열명.필드명) 이름.
start_indexnumber선택배열의 몇 번째 레코드부터 보여줄지. 기본 0.
showstring선택쉼표로 구분한 필드명 목록. 생략하면 전체 필드를 표시합니다.
orientationstring선택"horizontal"(기본, 행=레코드/열=필드) 또는 "vertical"(행=필드/열=레코드). 필드가 많을 때 vertical을 씁니다.
vertical_rowsnumber선택vertical일 때 한 화면에 나란히 볼 레코드(열) 수. 기본 1. ←/→로 다음/이전 레코드 묶음 이동, PgUp/PgDn은 필드(행) 스크롤.
filterstring선택초기 필드 조건 필터 문자열. 빈 문자열이면 필터 없이 시작하되 filter_key(이 매뉴얼은 Ctrl+F)로 나중에 입력 가능.
editable_fieldsstring선택쉼표로 구분한 화이트리스트 필드명 — 화면에서 커서로 값을 바꿔 SHM에 즉시 write할 수 있는 필드를 지정합니다(SSH TUI 전용). 자세한 예시는 6. SHM 값 편집 참고.

전문별 처리 건수 — 헤더+데이터 SHM, 한글 라벨, 다단 배치

거래소 전문(메시지 타입)별 수신/처리/DB저장 건수 — 실무에서 가장 흔한 운영 모니터링 패턴입니다. 이 예시는 "헤더 구조체(등록된 전문 종류 수) + 데이터 배열" 조합을 쓰고, C 필드명을 그대로 노출하는 대신 field_labels로 한글 라벨을 붙이고, 60여 건을 list_cols = 2로 2단 배치해서 스크롤 없이 한눈에 보이게 합니다.

전문별 처리 건수

거래소 전문(메시지 타입)별 수신/처리/DB저장 건수 — 실무에서 가장 흔한 운영 모니터링 패턴입니다.

{
  label   = "전문별 처리 건수",
  view    = "manual_msgstats_cfg",
  shm_key = 0x1300,
  header  = "examples/msg_stats.h",
  struct  = "MSG_STATS.data",
  header_struct = "MSG_STATS_HEADER",
  count_field   = "cur_cnt",  -- head.cur_cnt만큼만 표시
  title = "전문별 처리 현황 (거래소 연동)",
  field_labels =
    "msg_code=전문코드,msg_name=전문명,recv_cnt=수신건수,"
    .. "proc_cnt=처리건수,db_cnt=DB저장건수",
  hide = "db_cnt",
  list_cols = 2,  -- 60건을 2단으로 나란히. SSH·웹 둘 다 동일하게 2단 분할
}
실행 화면 · list_cols=2, field_labels 적용
전문별 처리 건수 화면 — 한글 컬럼명으로 2단 배치된 표
필드타입필수설명
header_structstring선택데이터 배열 앞에 붙은 헤더 구조체 이름. count_field와 짝으로 씁니다.
count_fieldstring선택헤더 구조체 안에서 "현재 등록된 레코드 수"를 담은 필드명. 배열 전체 크기가 아니라 이 값만큼만 표시합니다.
titlestring선택화면 제목. 생략하면 구조체명/헤더 경로가 그대로 노출됩니다.
field_labelsstring선택C필드명=한글라벨을 쉼표로 나열한 문자열. C 필드명을 운영자용 라벨로 바꿔서 보여줍니다.
hidestring선택쉼표로 구분한, 숨길 필드명 목록(C 필드명 기준).
list_colsnumber선택행을 N개 그룹으로 나눠 좌우로 나란히 배치. 생략/1이면 단일 컬럼. SSH TUI·웹 대시보드 양쪽에서 동일하게 동작합니다.
title / field_labels / hide / list_cols는 SHM 구조체 뷰 전용 config.lua 필드입니다. C 필드명(msg_code, recv_cnt...)을 운영자가 보기 편한 한글 제목으로 바꾸고, 불필요한 컬럼은 숨기고, 행이 많으면 여러 단으로 나란히 배치합니다 — 전부 재컴파일 없이 config.lua만 고치면 바로 반영됩니다.

manual-1 · 2

같은 화면을 Lua 파일로

바로 위 8개 화면을 이번엔 file=로 엽니다. 재구현이 아닙니다 — tapas.* 바인딩이 core의 같은 C 함수를 그대로 호출해서, 화면은 1번 항목과 픽셀 단위로 동일합니다. 보통 config.lua로 시작했다가, 이 화면만의 로직(색상 임계값, 파생 컬럼 등)이 필요해지면 Lua 파일로 옮겨갑니다 — 그 과정은 manual-2에서 이어집니다. (예외: 아래 "전문별 처리 건수"는 일부러 다르게 구현 — 이유는 바로 아래 참고.)

Lua시스템 상태

manual-1/lua/lua_system.lua — tapas.system_render()가 view_system.c를 그대로 호출

local M = {}

function M.update()
    tapas.system_update()
end

function M.render(p)
    tapas.system_render(p)
end

return M

Lua프로세스 목록

manual-1/lua/lua_process.lua — tapas.view_table("process")가 core 데이터를 그대로 반환

local M = {}

function M.update() end

-- VT_BUILTIN 전용 키 핸들러는 file= 뷰에 안 붙는다 — PgUp/PgDn을
-- config-only 쪽과 똑같이 쓰려면 스크롤 위치를 직접 들고 있어야 한다.
local scroll = 0
local last_page_size, last_total = 20, 0

function M.render(p)
    local t = tapas.view_table("process")
    last_page_size = p.rows - 1
    last_total = #t.rows
    tapas.render_table(p, 0, t.headers, t.rows, scroll)
end

function M.handle_key(key)
    local handled
    scroll, handled = tapas.scroll_key(key, scroll, last_total, last_page_size)
    return handled
end

return M

Lua전문별 처리 건수 — 이름은 매핑으로

manual-1/lua/lua_msgstats.lua (일부 발췌) — 실무 SHM에는 코드+건수만 있고 이름 필드가 없는 경우가 많습니다

-- config-only 쪽은 SHM에 있는 msg_name 필드를 그대로 보여주지만, 이
-- 파일은 일부러 그 필드를 안 쓰고 코드→이름 매핑을 Lua 배열로 따로
-- 들고 있다가 붙여서 보여준다 — SHM에 이름이 없는 실무 상황을 흉내낸
-- 것이다. 매핑에 없는 코드는 이름 칸만 비우고, 행 자체는 그대로 보여준다.
local MSG_NAMES = {
    ORD00 = "신규주문", ORD01 = "정정주문", CHE00 = "체결통보",
    QUO00 = "현재가시세", QUO03 = "시간외단일가",
    -- ... (전체 60여 개는 manual-1/lua/lua_msgstats.lua 참고)
}

function M.render(p)
    -- SHM 구조체 뷰 엔진(tapas.shmstruct_render)은 못 쓴다 — 파싱한
    -- 필드를 그대로 보여줄 뿐 없는 값(이름)을 만들어내지 못해서, 원본만
    -- 읽고(shmstruct_read) 표는 직접 그린다.
    local rows_raw = tapas.shmstruct_read(spec)

    -- config.lua의 title=을 그대로 읽어 config-only 항목과 같은 제목을
    -- 보여준다 — 직접 그리는 만큼 제목도 직접 그려야 한다.
    tapas.render_line(p, 0, {
        { name = tapas.title(), value = string.format("%d건", #rows_raw) },
    })

    local rows = {}
    for _, r in ipairs(rows_raw) do
        table.insert(rows, {
            r.msg_code,
            MSG_NAMES[r.msg_code] or "",
            { value = r.recv_cnt, align = "right" },
            { value = r.proc_cnt, align = "right" },
            { value = r.db_cnt,   align = "right" },
        })
    end

    tapas.render_table(p, 2,
        { "전문코드", "전문명", "수신건수", "처리건수", "DB저장건수" },
        rows, 0, tapas.list_cols())
end
화면Lua에서 부르는 core 함수
로그tapas.log_update(path) / tapas.log_render(p, path)
메시지 큐 / 디스크 / 네트워크tapas.view_table("queue" | "disk" | "net") + tapas.render_table()
SHM 구조체 (Lua)tapas.shmstruct_update() / tapas.shmstruct_render(p) — config-only와 완전히 같은 필드 조건 필터(filter_key)도 그대로 지원
전문별 처리 건수예외 — tapas.shmstruct_read()로 원본만 읽고 SHM에 없는 전문명은 Lua 배열로 매핑해서 붙임 (tapas.title()로 제목만 config.lua 값을 읽어옴, field_labels/hide는 이 방식에서 안 통해서 헤더를 직접 지정)

manual-1 · 3

화면 여러 개를 한 번에

panels = { ... }로 이미 등록된 화면 여러 개를 한 항목에 묶으면 자동으로 분할된 대시보드가 됩니다. 각 패널은 완전히 독립된 view 인스턴스라서, 위에서 이미 스크린샷으로 본 시스템 상태·SHM 구조체 같은 화면이 그대로 한 그리드 안에 나란히 그려지는 것뿐입니다 — 대시보드 전용 렌더링 엔진이 따로 있는 게 아니라, 기존 view들을 panels_cols 그리드로 배치만 다르게 합니다. 각 패널의 filter·gauge 같은 세부 설정은 그 view id로 먼저 등록된 항목(보통 "기본기능"의 항목) 값을 그대로 물려받습니다.

필드타입필수설명
panelstable필수일반 메뉴 항목과 같은 형식의 테이블 배열. 각 항목이 그리드의 패널 하나가 됩니다.
panels_colsnumber선택한 줄에 배치할 패널 수. 1 이상이면 고정 컬럼 수, 생략하거나 0이면 패널 개수에 맞춰 자동 분할(정사각형에 가깝게).

종합 현황 (4분할)

시스템/프로세스/로그/SHM 구조체를 한 화면에. panels_cols 생략 = 자동분할

{
  label = "종합 현황 (4분할)",
  view  = "manual_dashboard_overview",
  panels = {
    { label = "시스템 상태", view = "system" },
    { label = "프로세스 목록", view = "process" },
    { label = "로그", view = "log" },
    { label = "SHM 구조체", view = "manual_shmstruct_dashboard",
      shm_key = 0x1234, header = "examples/koscom_stock.h",
      struct = "KOSCOM_STOCK_A0", orientation = "vertical" },
  },
}

종합 현황 (컬럼지정)

panels_cols로 한 줄에 몇 개를 둘지 고정합니다. 1 이상이면 고정, 생략/0이면 자동

{
  label = "종합 현황 (컬럼지정)",
  view  = "manual_dashboard_cols",
  panels_cols = 3,
  panels = {
    { label = "시스템 상태", view = "system" },
    { label = "프로세스 목록", view = "process" },
    { label = "로그", view = "log" },
    { label = "SHM 구조체", view = "manual_shmstruct_dashboard",
      orientation = "vertical" },
  },
}

종합 현황 (Lua) 항목은 같은 3개 화면을 manual-1/lua/lua_dashboard.lua로 나열한 것인데, 이 파일은 update()/render() 없이 panels = {...} 테이블만 반환합니다. 그러면 config.lua 선언 쪽과 완전히 같은 C 엔진 (view_dashboard.c)이 그리므로, gauge·filter 같은 세부 설정까지 한 글자도 안 틀리고 동일하게 나옵니다 — "Lua 파일에 로직을 다시 짜지 않고 선언만 옮겨 적어도 똑같이 동작한다"는 이 프로젝트의 원칙을 그대로 보여주는 예시입니다.

manual-1 · 4

MySQL / PostgreSQL / Altibase 조회

DB 클라이언트 라이브러리를 링크하지 않습니다 — mysql/psql/isql 같은 표준 CLI를 io.popen()으로 실행하고 그 출력을 표로 파싱해서 보여줍니다. 새 DB를 추가하고 싶으면 CLI 하나 실행할 수 있는 서버라면 바로 됩니다 — 커넥터 설치도, 재빌드도 필요 없습니다.

db_*.lua 상단 6개 값만 바꾸면 됩니다(재빌드 불필요) — 헷갈리는 건 host/port 뿐, 아래 3가지 중 하나입니다.

1. 실제 DB 서버가 있다

그 서버의 IP 주소를 host에.

host = "192.168.0.50"
port = 5432

2. docker + 포트 열림

-p로 host 포트를 열었다면 host는 항상 "127.0.0.1". PostgreSQL 데모가 이 경우.

docker run -p 127.0.0.1:5432:5432 postgres

host = "127.0.0.1"
port = 5432

3. docker + 포트 안 열림

TCP 접속 자체가 불가 → docker exec로 컨테이너 안 클라이언트를 호출. MySQL/Altibase 데모가 이 경우(아래 코드 참고).

정한 값으로 db_*.lua를 고치기 전에 터미널에서 먼저 접속 테스트 — 여기서 안 되면 코드를 고쳐도 똑같이 안 됩니다.
which psql   # 또는 mysql / isql — 클라이언트가 있는지 확인

psql -h 127.0.0.1 -p 5432 -U <user> -d <database>   # 접속 테스트

# 되면 그 값을 db_*.lua 상단에 그대로 옮기면 끝
db_*.lua 변수넣을 값예시
host위 1/2/3번 참고"192.168.0.50"
port기본값: PostgreSQL 5432 / MySQL 3306 / Altibase 203005432
user접속 계정"readonly_user"
password없으면 빈 문자열 """s3cr3t"
database접속할 DB(스키마) 이름"sales"
query컬럼이 그대로 화면 표 헤더가 됨"SELECT id, name, amount FROM orders LIMIT 50;"
local host     = "192.168.0.50"
local port     = 5432
local user     = "readonly_user"
local password = "s3cr3t"
local database = "sales"
local query    = "SELECT id, name, amount FROM orders LIMIT 50;"
화면사용 CLI비고
MySQLmysql데모 컨테이너 안 mysql 클라이언트를 docker exec로 호출 — 비밀번호는 MYSQL_PWD 환경변수로 전달, host에는 클라이언트를 설치하지 않음
PostgreSQLpsqlhost에 이미 있는 psql로 TCP 직접 접속 — 동일한 lua/db/db_common.lua 골격 재사용
Altibaseisql데모 컨테이너 안 isql을 docker exec로 호출 — 구분자 출력 옵션이 없어 SET HEADING OFF+필드 연결(||)로 우회

MySQL 조회 (lua/db/db_mysql.lua)

테스트 테이블 koscom_stock_a3_test(체결) 조회. host에는 mysql CLI를 안 깔고 docker exec로 컨테이너 안 클라이언트만 씀.

-- MySQL 조회 뷰: mysql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 mysql CLI를 설치하지 않고, docker 컨테이너(tapas-eye-demo-mysql)
-- 안의 mysql 클라이언트를 `docker exec`로 그대로 호출한다 — TCP 접속정보
-- (host/port) 대신 컨테이너 이름을 쓴다는 점만 db_postgresql.lua와 다르다.
-- 접속정보/쿼리는 아래 값만 고치면 됨(재빌드 불필요). 비밀번호는 커맨드라인
-- 인자 대신 MYSQL_PWD 환경변수로 넘겨 `ps aux` 노출을 막는다(단
-- /proc/pid/environ로는 여전히 보일 수 있음).
--
-- 예제 쿼리는 examples/koscom_stock.h의 KOSCOM_STOCK_A3(체결) 구조체 필드 중
-- 일부(종목코드/체결가격/체결수량/시가/고가/저가)를 그대로 옮긴 테스트 테이블
-- koscom_stock_a3_test를 조회한다 — db_postgresql.lua가 쓰는 KOSCOM_STOCK_A0
-- (종목배치)와는 다른 구조체/필드라 표시되는 값도 다르다. 테이블/샘플 데이터는
-- lua/db/db_mysql_seed.sql로 만든다.
local common = dofile("lua/db/db_common.lua")

local container = "tapas-eye-demo-mysql"
local user       = "root"
local password   = ""
local database   = "test"
local query      = "SELECT isin, exec_prc, exec_qty, open_prc, high_prc, low_prc FROM koscom_stock_a3_test;"

local function build_cmd()
    return string.format(
        "docker exec -e MYSQL_PWD=%s %s mysql -u%s %s -B -e %s",
        common.shell_quote(password),
        common.shell_quote(container),
        common.shell_quote(user),
        common.shell_quote(database),
        common.shell_quote(query)
    )
end

return common.new_view({ build_cmd = build_cmd, sep = "\t", refresh_sec = 5 })

PostgreSQL 조회 (lua/db/db_postgresql.lua)

테스트 테이블 koscom_stock_a0_test(종목배치) 조회. host에 이미 있는 psql로 TCP 직접 접속.

-- PostgreSQL 조회 뷰: psql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- 접속정보/쿼리는 아래 값만 고치면 됨 (재빌드 불필요). 비밀번호는 커맨드라인
-- 인자 대신 PGPASSWORD 환경변수로 넘긴다 — 더 안전한 대안은 ~/.pgpass.
--
-- 예제 쿼리는 examples/koscom_stock.h의 KOSCOM_STOCK_A0(코스피/코스닥/코넥스
-- 배치) 구조체 필드 중 일부(종목코드/한글약명/기준가/전일종가/상한가/하한가)를
-- 그대로 옮긴 테스트 테이블 koscom_stock_a0_test를 조회한다 — DB 조회 결과가
-- SHM 구조체 뷰와 같은 필드를 보여준다는 걸 확인하기 위한 예시일 뿐, 실제
-- 운영 스키마는 아니다. 테이블/샘플 데이터는 lua/db/db_postgresql_seed.sql로
-- 만든다.
local common = dofile("lua/db/db_common.lua")

local host     = "127.0.0.1"
local port     = 5432
local user     = "postgres"
local password = ""
local database = "postgres"
local query    = "SELECT isin, kor_name, base_price, prev_close, upper_limit, lower_limit FROM koscom_stock_a0_test;"

local function build_cmd()
    return string.format(
        "PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -A -F %s -c %s",
        common.shell_quote(password),
        common.shell_quote(host),
        tostring(port),
        common.shell_quote(user),
        common.shell_quote(database),
        common.shell_quote("|"),
        common.shell_quote(query)
    )
end

return common.new_view({ build_cmd = build_cmd, sep = "|", refresh_sec = 5 })

Altibase 조회 (lua/db/db_altibase.lua)

테스트 테이블 msg_db_stats_test(전문별 DB저장 건수) 조회. isql은 구분자 출력이 없어 우회 필요.

-- Altibase 조회 뷰: isql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 아무것도 설치하지 않고, docker 컨테이너(tapas-eye-demo-altibase,
-- altibase/a_plus_edition — 라이선스 없이 도는 무료 에디션) 안의 isql을
-- `docker exec`로 그대로 호출한다 — db_mysql.lua와 같은 패턴.
--
-- isql은 mysql -B / psql -A 같은 "구분자 출력" 옵션이 없다(고정폭 표 +
-- 헤더밑줄 + "N rows selected." 꼬리말만 지원). 그래서 두 가지로 우회한다:
--   1. `set heading off;`로 isql 자체 헤더/밑줄을 끄고, 진짜 헤더는 우리가
--      echo로 직접 앞에 붙인다.
--   2. 쿼리 자체에서 SELECT ... || '|' || ... 로 각 행을 이미 구분자로 이어
--      붙여서 내보낸다 — isql이 남기는 컬럼 우측 공백 패딩은 sed로 벗겨낸다.
-- 결과물은 mysql/psql이 내는 것과 같은 모양(헤더 줄 + 구분자로 나뉜 데이터
-- 줄 + "N rows selected.")이 되어 db_common.lua를 그대로 재사용할 수 있다.
--
-- 예제 쿼리는 examples/msg_db_stats.h의 DB_STATS_DATA(DB적재 프로세스가
-- 전문별로 남기는 DB저장 건수) 구조체 필드(전문코드/DB저장건수)를 그대로
-- 옮긴 테스트 테이블 msg_db_stats_test를 조회한다 — db_postgresql.lua/
-- db_mysql.lua가 쓰는 koscom_stock.h(시세) 구조체들과는 다른 구조체라 값도
-- 다르다. 테이블/샘플 데이터는 lua/db/db_altibase_seed.sql로 만든다.
local common = dofile("lua/db/db_common.lua")

local container = "tapas-eye-demo-altibase"
local user       = "sys"
local password   = "manager"
local headers    = { "msg_code", "db_cnt" }
local query      = "SELECT msg_code || '|' || db_cnt FROM msg_db_stats_test;"

local function build_cmd()
    local isql_cmd = string.format(
        "$ALTIBASE_HOME/bin/isql -u %s -p %s -s localhost -silent -noprompt",
        user, password
    )
    return string.format(
        "{\necho %s\ndocker exec -i %s bash -lc %s <<'ALTIBASE_SQL'\nset heading off;\n%s\nquit;\nALTIBASE_SQL\n} | sed 's/[ \t]*$//'",
        common.shell_quote(table.concat(headers, "|")),
        common.shell_quote(container),
        common.shell_quote(isql_cmd),
        query
    )
end

return common.new_view({ build_cmd = build_cmd, sep = "|", refresh_sec = 5 })
필드 (common.new_view 옵션)타입필수설명
build_cmdfunction필수실행할 셸 명령 문자열을 반환하는 함수. common.shell_quote()로 값마다 인용해서 셸 인젝션을 막습니다.
sepstring선택CLI 출력의 컬럼 구분자. mysql/psql의 -B(배치 모드) 출력은 탭("\t") 구분입니다.
refresh_secnumber선택몇 초마다 CLI를 재실행해서 갱신할지. 매 프레임 재실행하지 않고 이 주기로만 새로 조회합니다.

이 페이지의 다른 예시들과 달리 이 항목은 실행 화면 스크린샷을 아직 싣지 않았습니다 — DB 조회 예제는 데모용 docker 컨테이너(MySQL/ PostgreSQL/Altibase 각각 하나씩)에 접속하는 구성이라 스크린샷이 서버쪽 인프라 변경에 따라 쉽게 낡을 수 있어서, 코드 스니펫과 표로만 안내합니다. 접속 정보만 올바르면 CLI가 출력하는 그대로(오류 메시지 포함) 표로 렌더링된다고 이해하시면 됩니다.

db_common.lua가 CLI 실행·구분자 파싱·주기적 재실행(refresh_sec)을 한 번만 구현해두고, DB별 파일(db_mysql.lua/db_postgresql.lua/ db_altibase.lua)은 접속정보와 쿼리 문자열만 다릅니다 — 쿼리를 바꾸고 싶으면 서버 관리자가 해당 파일 상단 query 한 줄만 고치면 됩니다. Altibase처럼 CLI가 구분자 출력을 안 지원하면 build_cmd() 안에서 우회 로직만 더 추가하면 되고, 이 경우에도 db_common.lua는 손대지 않습니다.

manual-1 · 5

SHM 3개를 하나의 표로 조인

SHM 구조체 뷰(view=)는 SHM 하나만 다룹니다. 수신·처리·DB적재가 서로 다른 프로세스라 SHM도 각자 관리되는 게 실무에선 흔한데, 이런 조인은 config.lua만으로는 안 되고 Lua가 필요합니다 — 화면을 안 그리고 데이터만 raw로 가져오는 tapas.shmstruct_read(spec) API를 씁니다.

spec 필드 (shmstruct_read 인자)타입필수설명
shm_keynumber필수읽을 SHM의 키. config.lua view=의 같은 필드와 동일한 의미입니다.
header / structstring필수파싱할 헤더 경로와 구조체(배열)명. view=와 동일한 파서를 씁니다.
header_struct / count_fieldstring선택헤더+카운트 구조의 SHM일 때만 지정 — 등록된 레코드 수만큼만 읽어옵니다.

tapas.shmstruct_read(spec)는 화면을 그리지 않고 { {필드명=값, ...}, ... } 형태의 순수 Lua 테이블 배열만 돌려줍니다 — view=가 하는 "파싱+렌더링"에서 렌더링만 뺀 것이라고 생각하면 됩니다. 이 raw 배열을 여러 개 받아다가 아래처럼 직접 합치는 로직을 짭니다.

전문별 처리 건수 (SHM 3개 조인)

manual-1/lua/lua_msgjoin.lua — 세 SHM을 msg_code 기준으로 합친다

local RECV_SPEC = { shm_key = 0x1301, header = "examples/msg_recv_stats.h",
  struct = "RECV_STATS.data", header_struct = "RECV_STATS_HEADER", count_field = "cur_cnt" }
local PROC_SPEC = { shm_key = 0x1302, header = "examples/msg_proc_stats.h",
  struct = "PROC_STATS.data", header_struct = "PROC_STATS_HEADER", count_field = "cur_cnt" }
local DB_SPEC   = { shm_key = 0x1303, header = "examples/msg_db_stats.h",
  struct = "DB_STATS.data",   header_struct = "DB_STATS_HEADER",   count_field = "cur_cnt" }

function M.render(p)
    local recv = tapas.shmstruct_read(RECV_SPEC)
    local proc = tapas.shmstruct_read(PROC_SPEC)
    local db   = tapas.shmstruct_read(DB_SPEC)

    -- msg_code로 세 SHM을 합친다 (등장 순서를 보존)
    local by_code, order = {}, {}
    local function row_for(code)
        if not by_code[code] then
            by_code[code] = { recv_cnt = "0", proc_cnt = "0", db_cnt = "0" }
            table.insert(order, code)
        end
        return by_code[code]
    end
    for _, r in ipairs(recv) do row_for(r.msg_code).recv_cnt = r.recv_cnt end
    for _, r in ipairs(proc) do row_for(r.msg_code).proc_cnt = r.proc_cnt end
    for _, r in ipairs(db)   do row_for(r.msg_code).db_cnt   = r.db_cnt   end

    local rows = {}
    for _, code in ipairs(order) do
        local v = by_code[code]
        table.insert(rows, { code,
            { value = v.recv_cnt, align = "right" },
            { value = v.proc_cnt, align = "right" },
            { value = v.db_cnt,   align = "right" },
        })
    end

    tapas.render_table(p, 1,
        { "전문코드", "수신건수 (SHM_A)", "처리건수 (SHM_B)", "DB저장건수 (SHM_C)" },
        rows, 0, tapas.list_cols())
end
실행 화면 · SHM 3개 조인 결과
SHM 3개를 조인한 화면 — ORD05처럼 일부 SHM에 없는 코드는 0으로 채워짐
일부 SHM에는 없는 코드(예: 실시간 시세라 DB에 저장 안 하는 항목, 방금 추가돼 아직 처리단에 안 반영된 신규 전문)도 안전하게 "0"으로 채워서 표시됩니다. 셀을 { value = ..., align = "right" }로 주면 숫자 컬럼을 우측정렬할 수 있고, tapas.list_cols()로 config.lua의 list_cols=N 값을 그대로 읽어와 여러 단으로 나란히 배치할 수도 있습니다 — SSH 터미널과 웹 브라우저 어느 쪽에서 봐도 동일합니다.

manual-1 · 6

화면에서 SHM 값을 바로 수정 — editable_fields

지금까지 본 SHM 화면은 전부 읽기 전용이었습니다. 하지만 로그 레벨처럼 "다른 프로세스가 SHM에 남겨둔 값을, 그 프로세스를 재시작하지 않고 운영자가 그 자리에서 바로 바꾸고 싶은" 값도 실무에 흔합니다. editable_fields에 화이트리스트로 올린 필드만, 화면에서 Enter로 커서를 두고 값을 입력하면 즉시 그 SHM에 write됩니다 — 나머지 필드는 그대로 읽기 전용으로 보호됩니다.

SSH TUI 전용 기능 — 웹 대시보드에서도 볼 수는 있지만 값 편집은 SSH 세션에서만 됩니다(웹은 인증 없는 내부망 전용이라 쓰기 동작을 의도적으로 뺐습니다). 웹에서 열면 그 사실을 알리는 안내 배너가 뜹니다.

런타임 설정값 (config)

loglevel 필드 하나만 화이트리스트에 올려서 화면에서 바로 수정 가능하게 합니다.

{
  label   = "런타임 설정값 (config)",
  view    = "manual_ctrl_cfg",
  shm_key = 0x1350,
  header  = "examples/loglevel_ctrl.h",
  struct  = "LOGLEVEL_CTRL",
  editable_fields = "loglevel",  -- 이 필드만 Enter로 값 수정 가능
  title = "런타임 설정값 (SHM, loglevel만 값 직접 수정 가능)",
  field_labels =
    "loglevel=로그레벨(0~3),max_conn=최대연결수(읽기전용),"
    .. "service_name=서비스명(읽기전용)",
  filter = "",
}

런타임 설정값 (Lua)

manual-1/lua/lua_ctrl.lua — config 쪽과 완전히 같은 화면·동작.

local spec = {
  shm_key = 0x1350,
  header  = "examples/loglevel_ctrl.h",
  struct  = "LOGLEVEL_CTRL",
}

function M.update()
  tapas.shmstruct_update(spec)
end

function M.render(p)
  tapas.shmstruct_render(p, spec)
end

function M.handle_key(key)
  return tapas.shmstruct_handle_key(key)
end
-- editable_fields는 이 spec이 아니라 config.lua 메뉴 항목 쪽에
-- 지정합니다(위 config 예시와 동일한 자리) — Lua spec 테이블에
-- 넣어도 효과가 없습니다.
필드타입필수설명
editable_fieldsstring선택쉼표로 구분한 화이트리스트 필드명. 목록에 있는 필드만 화면에서 커서로 값을 바꿀 수 있고, 나머지 필드는 Enter를 눌러도 "편집할 수 없습니다" 안내만 뜹니다. config-only(view=)/Lua(file=) 두 방식 모두 이 필드는 config.lua 메뉴 항목 쪽에서만 지정합니다.
키동작
Enter셀 커서 진입
방향키커서 이동
Enter (편집 가능한 셀에서 한 번 더)현재 값이 채워진 입력 팝업 → 값 입력 후 Enter로 제출, 즉시 SHM에 write
ESC커서 모드 해제
이 예시는 loglevel(0=ERROR~3=DEBUG)만 화이트리스트에 올렸고, max_conn/service_name은 일부러 빼서 대조군으로 남겨뒀습니다 — 실수로 아무 필드나 못 바꾸게 막는 안전장치도 데모의 일부입니다.

manual-1 · 7

config.lua를 화면에서 직접 편집

지금까지는 화면에 무엇을 보여줄지를 config.lua 파일을 직접 열어 텍스트로 정의했습니다. 설정 편집기는 그 config.lua 자체를 화면에서 편집합니다 — 항목 추가·삭제·순서 변경, label/hotkey/filter/gauge 같은 필드 수정까지 전부 화면 안에서 됩니다. 웹 브라우저와 SSH TUI 양쪽에 각각 있습니다.

웹 (/config)

웹 대시보드를 띄운 서버에서 브라우저로 /config에 접속합니다. 다른 화면과 같은 인증-없음/내부망 전용 전제를 그대로 따릅니다 — 외부에 노출하지 마세요.

TUI (SSH)

config.lua에 config_editor_key를 지정해두면 메뉴 항목 없이 그 키 하나로 어디서든 진입합니다. 아래 F-키가 OS 단축키에 가로채이는 환경에서는 Ctrl 조합을 씁니다.

manual-1/config.lua

이 데모에도 실제로 켜져 있습니다 — SSH로 접속했다면 지금 바로 Ctrl+E를 눌러보세요.

# Ctrl 조합 표기(개요 참고) — macOS에서 F11이 OS 단축키에 가로채이는 게
# 실측 확인돼서 이 데모는 기본값을 F-키 대신 Ctrl+E로 씀
config_editor_key = "Ctrl+E"
미리보기(그림) · 항목 목록 화면
▸ 시스템 상태
▸ 프로세스 목록
▸ 로그Enter=편집
▸ 웹 서비스
+ 새 항목
키동작
↑ / ↓목록 이동
Enter선택한 항목의 필드 편집 화면 진입, 또는 "+ 새 항목"에서 추가 시작(종류→id→라벨 3단계)
d선택한 항목 삭제(확인 팝업)
[ / ]선택한 항목을 형제 목록에서 위/아래로 재배치
rconfig.lua 리로드 — 필드 값 변경을 실행 중 화면에 반영
ESC필드 편집 화면이면 목록으로, 목록 화면이면 나가기
필드 값 변경은 r로 즉시 리로드해서 실행 중 화면에 반영되지만, 항목 추가·삭제·순서 변경 같은 구조 변경은 재시작해야 반영됩니다 (웹 편집기도 같은 제약). 저장 전에 config.lua 문법을 검증하고 원자적으로 교체하므로, 편집 중 실수해도 실행 파일이 깨진 config.lua를 읽어서 죽는 일은 없습니다.

manual-1 · 8

웹 서비스 켜고 끄기

브라우저 대시보드는 세 가지 방법 중 아무거나로 켤 수 있고, 셋 다 같은 config.lua/화면 코드를 그대로 씁니다 — 웹용으로 뭘 따로 만들 필요는 없습니다.

1. 실행과 동시에

--web-port를 커맨드라인에 직접 줍니다. 가장 빠른 방법 — SSH TUI와 브라우저가 같이 뜹니다.

2. 메뉴에서 나중에

config.lua에 web_port만 적어두면 자동 기동은 안 되고, 실행 중에 메뉴의 "웹 서비스" → "시작"을 눌렀을 때만 켜집니다.

3. SSH 없이 백그라운드로

TUI 앱이라 stdin에 tty가 필요해서 nohup ... &은 즉시 종료됩니다 — tmux/screen 세션 안에서 실행하세요.

manual-1/config.lua

이 데모도 실제로 이렇게 설정돼 있습니다 — 자동 기동은 아니고, 메뉴의 "웹 서비스"에서 켜야 합니다.

web_port = 8000
-- web_bind = "127.0.0.1"  -- 생략하면 0.0.0.0(모든 인터페이스)

-- menus 안에 추가
{
    label = "웹 서비스",
    web = false,  -- 웹 화면에서 웹 서버 자기 자신을 껐다 켰다 하는 건
                  -- 의미가 없음(끄면 보고 있던 그 화면도 끊김)
    items = {
        { label = "시작", action = "web_start" },
        { label = "중지", action = "web_stop" },
    },
},

백그라운드로 (쉘)

tmux 세션 하나로 SSH 접속 없이 계속 띄워둡니다.

tmux new-session -d -s tapas-web \
    './bin/tapas-eye --web-port 8080'

# 나중에 들여다보기
tmux attach -t tapas-web
# 끌 때
tmux kill-session -t tapas-web
미리보기(그림) · "웹 서비스" 메뉴
웹 서비스● 8000 (LIVE)
▸ 시작
▸ 중지
포트 자동 대체 — 설정된 포트가 이미 사용 중이면 다음 포트(+1, +2, … 최대 20개)를 자동으로 시도합니다. 실제로 쓴 포트는 설정된 포트 8080은(는) 이미 사용 중이어서 8081번 포트로 대신 시작했습니다처럼 팝업 + 상태줄로 알려주고, 커맨드라인 --web-port로 켤 때도 동일하게 동작합니다.
설정 편집기(웹 /config, TUI config_editor_key)에서 web_port/ web_bind 값 자체도 화면에서 바로 바꿀 수 있습니다 — r로 리로드하면, 켜져 있던 웹 서비스는 옛 포트에 그대로 떠 있지 않도록 자동으로 한 번 내려갑니다(새 포트로 다시 켜려면 "시작"을 다시 누르면 됩니다).
외부 접속에는 방화벽 개방이 필요합니다 — --web-port는 web_bind를 따로 안 주면 모든 인터페이스(0.0.0.0)에 바인드되지만, 서버 자체 방화벽(ufw 등)이나 클라우드 보안 그룹이 그 포트를 막고 있으면 여전히 외부에서 못 붙습니다. 켠 포트 번호로 ufw allow <port>/tcp 같은 규칙을 별도로 추가해야 합니다.
HTTPS 도메인 안에 iframe/링크로 심을 때는 same-origin 프록시가 필요합니다 — 이 홈페이지도 처음엔 라이브 데모 iframe을 http://IP:PORT로 직접 걸었다가, 브라우저가 HTTPS 페이지 안의 http:// 서브리소스를 mixed-content로 조용히 차단해서 화면이 안 뜨는 문제를 겪었습니다(새 탭으로 직접 열면 최상위 이동이라 안 걸려서 그쪽만 정상으로 보였습니다). 해결은 nginx로 같은 도메인 하위 경로에서 그 포트로 리버스 프록시하는 것 — 실시간 갱신(웹소켓)까지 쓰는 화면이라 업그레이드 헤더도 함께 넘겨야 합니다.
location /demo/ {
    proxy_pass http://127.0.0.1:8081/;   # 트레일링 슬래시로 /demo/ 접두사를 벗겨서 전달
    proxy_set_header Host $host;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
manual-2 · 1부

데이터 가공하기

core가 이미 수집해 둔 데이터를 tapas.view_table()/tapas.system_read()로 그대로 가져다가, 화면 입맛에 맞게 가공만 합니다. "새로 뭘 만든다"보다 "이미 있는 걸 가져다 쓴다"에 가깝습니다 — config.lua 선언만으로는 안 되지만, Lua 몇 줄이면 되는 수준입니다. 아래 세 예제는 전부 같은 데이터 구조를 읽고 씁니다 — 그 구조를 먼저 알아두면 세 예제가 왜 그렇게 짧은지 이해가 됩니다.

구조타입설명
t.headersstring[]tapas.view_table(id)가 돌려주는 테이블의 컬럼 제목 배열.
t.rowstable[]행 배열. 각 행은 셀의 배열이고, 각 셀은 "문자열"이거나 { value=, color=, align= } 형태의 테이블입니다.
cell.colorstring"normal" | "good" | "warn" | "danger". 지정 안 하면 core가 정한 기본 색.
cell.alignstring"right"로 주면 숫자 컬럼처럼 우측정렬됩니다.

1필드 하나 추가하기

process 뷰 결과에 CPU%+MEM% 파생 컬럼 하나를 계산해서 붙입니다.

function M.render(p)
    local t = tapas.view_table("process")

    -- 원본 헤더 뒤에 파생 컬럼 하나만 추가한다
    local headers = {}
    for i, h in ipairs(t.headers) do headers[i] = h end
    table.insert(headers, "CPU+MEM")

    local rows = {}
    for i, row in ipairs(t.rows) do
        local new_row = {}
        for j, cell in ipairs(row) do new_row[j] = cell end
        local cpu = tonumber(row[3].value) or 0
        local mem = tonumber(row[4].value) or 0
        table.insert(new_row, string.format("%.1f", cpu + mem))
        rows[i] = new_row
    end

    tapas.render_table(p, 0, headers, rows, scroll, tapas.list_cols())
end

2조건에 따라 색 칠하기

core가 정한 기본 색 대신, 이 화면만의 임계값으로 다시 색을 정합니다.

local WARN_CPU, DANGER_CPU = 1.0, 5.0

function M.render(p)
    local t = tapas.view_table("process")
    local rows = {}
    for i, row in ipairs(t.rows) do
        local new_row = {}
        for j, cell in ipairs(row) do new_row[j] = cell end

        local cpu = tonumber(row[3].value) or 0
        local color = "normal"
        if cpu >= DANGER_CPU then color = "danger"
        elseif cpu >= WARN_CPU then color = "warn" end
        new_row[3] = { value = row[3].value, color = color }

        rows[i] = new_row
    end
    tapas.render_table(p, 0, t.headers, rows, scroll, tapas.list_cols())
end

3여러 소스 합치기

view=/file= 항목 하나는 소스 하나만 가리킵니다 — 여러 tapas.* 호출을 한 화면에 모읍니다.

function M.render(p)
    local sys   = tapas.system_read()
    local queue = tapas.view_table("queue")
    local disk  = tapas.view_table("disk")

    local rows = {
        { name = "CPU 사용률", value = string.format("%d%%", sys.cpu_pct) },
        { name = "메모리 사용률",
          value = string.format("%d%% (%d/%dMB)", sys.mem_pct, sys.mem_used_mb, sys.mem_total_mb) },
        { name = "로드 애버리지",
          value = string.format("%.2f / %.2f / %.2f", sys.load1, sys.load5, sys.load15) },
        { name = "메시지 큐 개수", value = tostring(#queue.rows) },
        { name = "디스크 마운트 수", value = tostring(#disk.rows) },
    }
    tapas.render_kv(p, rows, 0, tapas.list_cols())
end
manual-2 · 2부

진짜 프로그램 만들기

데이터를 가져다 가공하는 수준을 넘어, 이 화면만의 로직 — 상태를 들고 이력을 쌓거나, 이동평균·연속 횟수 같은 판단을 직접 계산하는 것 — 을 실제로 프로그래밍합니다. tapas.*는 원시값만 주고, "판단"은 전부 이 파일이 알고리즘으로 짠 것입니다.

4이력 저장해서 그래프 그리기

config.lua는 "지금 이 순간의 값"만 다룹니다. 추이를 보여주려면 매 갱신마다 값을 어딘가에 쌓아야 하는데, Lua 모듈의 최상위(local history_cpu = {})에 저장하면 그 값이 update()와 render() 호출 사이, 그리고 다음 갱신 때까지 계속 유지됩니다 — 파일이나 DB 없이 메모리 안에서 최근 MAX_POINTS개만 들고 있는 링 버퍼 역할입니다.

4이력 저장해서 그래프 그리기

manual-2/lua/lua_step4_history_graph.lua

local MAX_POINTS = 60
local history_cpu, history_mem = {}, {}  -- 모듈 최상위 = update()/render() 사이 유지

function M.update()
    local sys = tapas.system_read()
    table.insert(history_cpu, sys.cpu_pct)
    table.insert(history_mem, sys.mem_pct)
    if #history_cpu > MAX_POINTS then table.remove(history_cpu, 1) end
    if #history_mem > MAX_POINTS then table.remove(history_mem, 1) end
end

function M.render(p)
    local charts = {
        { name = "CPU %", values = history_cpu, max = 100, warn_at = 0.6, danger_at = 0.85 },
        { name = "MEM %", values = history_mem, max = 100, warn_at = 0.6, danger_at = 0.85 },
    }
    tapas.render_linegraph(p, 0, charts, 8, "area")
end
실행 화면 · render_linegraph(..., "area")
CPU/MEM 이력을 area 스타일 라인그래프로 그린 화면
인자 (charts 배열의 항목)타입필수설명
namestring필수그래프 옆에 표시할 라벨과 현재값.
valuesnumber[]필수시계열 값 배열. 오래된 값이 앞, 최신 값이 뒤(오른쪽 끝)입니다.
maxnumber선택그래프 y축 상한. 이 값 대비 비율로 그립니다.
warn_at / danger_atnumber선택max 대비 비율(0~1). 이 임계값을 넘으면 선 색이 경고/위험 색으로 바뀝니다.
render_linegraph(p, idx, charts, height, style)functionstyle는 "area"(채움) 또는 "line"(선만). height는 차트 하나당 줄 수(TUI 기준)입니다.

5평균 대비 급등 감지 알람

이동평균과 연속 초과 횟수를 직접 계산해서, 순간 튐은 걸러내고 진짜 경보만 잡습니다. SPIKE_RATIO(이동평균의 몇 배부터 "튐"으로 볼지), MIN_BASELINE(이 평균 이상일 때만 판단 — 평소 값이 거의 0인 지표가 사소한 변화에도 경보를 울리는 걸 방지), ALERT_AFTER(몇 틱 연속으로 튀어야 진짜 ALERT로 볼지) 세 상수만 화면마다 다르게 조정하면 됩니다.

5평균 대비 급등 감지 알람

manual-2/lua/lua_step5_anomaly_alert.lua

local SPIKE_RATIO, MIN_BASELINE, ALERT_AFTER = 1.5, 5, 3
local history, streak = {}, 0

function M.update()
    local sys = tapas.system_read()
    table.insert(history, sys.cpu_pct)
    if #history > 30 then table.remove(history, 1) end
end

function M.render(p)
    local cur = history[#history] or 0
    local sum = 0
    for _, v in ipairs(history) do sum = sum + v end
    local avg = #history > 0 and (sum / #history) or 0

    local spiking = avg >= MIN_BASELINE and cur >= avg * SPIKE_RATIO
    streak = spiking and (streak + 1) or 0

    local status, color = "정상", "good"
    if streak >= ALERT_AFTER then status, color = "ALERT", "danger"
    elseif spiking then status, color = "감시중", "warn" end

    tapas.render_table(p, 0,
        { "현재 CPU%", "이동평균(30틱)", "평균 대비", "연속 초과", "상태" },
        { { string.format("%.1f", cur), string.format("%.1f", avg),
            string.format("%+.0f%%", avg > 0 and (cur - avg) / avg * 100 or 0),
            tostring(streak), { value = status, color = color } } })
end
실행 화면 · 현재 상태: 정상 (급등 없음)
이동평균 대비 급등 감지 알람 화면 — 현재 CPU%, 이동평균, 평균 대비, 연속 초과, 상태 컬럼

두 화면 모두 원시값은 tapas.system_read() 하나뿐입니다. "이력을 쌓는다", "이동평균과 연속 횟수로 진짜 경보를 가려낸다"는 로직 자체가 config.lua로는 표현할 수 없는 영역이고, 이게 Lua 파일이 필요한 진짜 이유입니다.

manual-2 · 3부

C로 직접 짜기

tapas-eye의 진짜 강점입니다: Lua 표준 라이브러리(os/io/ string/table/math)에 없는 시스템 콜 — statvfs(), msgget()/msgctl() 같은 — 이 필요한 순간, C 함수 하나를 짜서 .so로 컴파일하고 Lua의 require()로 불러 쓰면 끝입니다. tapas.* API 자체가 이 방법(표준 Lua C 모듈)으로 만들어져 있고, 사용자 플러그인도 완전히 같은 규약·같은 헤더(tapas_plugin.h)로 tapas.*와 동등한 자격으로 동작합니다.

플러그인은 크게 두 패턴으로 나뉩니다 — 어느 쪽을 고르느냐에 따라 SSH 터미널에서만 동작하는지, 웹 대시보드에서도 동작하는지가 갈립니다.

패턴 A · 값만 반환 (권장)

C는 계산·조회만 하고 원시값(number/string/nil)만 Lua로 돌려줍니다. 화면은 항상 Lua 쪽에서 tapas.render_table()/render_kv()로 그립니다. disk_free.c, msgq_depth.c가 이 패턴이고, tapas.render_*()가 SSH(ANSI)와 웹(JSON) 출력을 알아서 갈라 처리하므로 SSH·웹 양쪽에서 그대로 동작합니다.

패턴 B · C가 직접 화면을 그림

C 함수 안에서 tui_move()/tui_puts() 같은 저수준 함수로 직접 ANSI를 그립니다. example_view.c가 이 패턴입니다. 빠르지만 SSH 터미널 전용입니다 — 이유는 아래 "왜 웹에서는 안 보이나"에서 다룹니다.

6plugins/disk_free.c — 패턴 A: 값만 반환

statvfs()는 표준 Lua(os/io)에 없는 시스템 콜이라 순수 Lua로는 흉내조차 낼 수 없습니다.

#include "tapas_plugin.h"
#include <sys/statvfs.h>

static int l_gb(lua_State *L) {
    const char *path = luaL_checkstring(L, 1);

    struct statvfs st;
    if (statvfs(path, &st) != 0) {
        lua_pushnil(L);
        return 1;
    }

    double free_bytes = (double)st.f_bsize * (double)st.f_bavail;
    lua_pushnumber(L, free_bytes / (1024.0 * 1024.0 * 1024.0));
    return 1;
}

static const luaL_Reg funcs[] = { {"gb", l_gb}, {NULL, NULL} };

int luaopen_disk_free(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}
C 쪽 규약 (매뉴얼에 반드시 있어야 하는 것)설명
luaopen_<name>파일명(disk_free.c → disk_free.so)과 함수명이 이 규약으로 연결됩니다 — Lua의 require("disk_free")가 내부적으로 luaopen_disk_free를 찾아 호출하는 표준 Lua C 모듈 규약 그대로입니다. 파일당 정확히 하나, 이름이 안 맞으면 require()가 심볼을 못 찾아 로드 자체가 실패합니다.
static int l_xxx(lua_State *L)Lua가 호출하는 C 함수는 전부 이 시그니처 하나뿐입니다. 인자는 luaL_checkstring()/luaL_checkinteger() 등으로 스택에서 꺼내고, 반환값은 lua_push*()로 스택에 쌓은 뒤 그 개수를 그대로 return합니다 — 예: 값 하나 반환은 lua_pushnumber(...); return 1;, 반환값 없음은 return 0;.
luaL_Reg funcs[]{"Lua쪽 이름", C 함수포인터} 배열. {NULL, NULL}로 끝나야 하며, luaL_newlib()가 이 배열로 Lua 테이블(모듈)을 만듭니다. 여기 적은 이름이 그대로 require("disk_free").gb처럼 노출됩니다.
tapas_plugin.hLua/lauxlib 헤더를 재노출하는 얇은 헤더 — 플러그인이 tapas-eye 내부 구조체에 의존하지 않고 표준 Lua C API(+ 아래 tui_* 원형)만으로 작성되게 강제합니다. 이 헤더 하나만 include하면 됩니다.
파일 전역(static) 변수.so가 로드돼 있는 동안 계속 유지됩니다(example_view.c의 s_tick처럼) — update()/render() 호출 사이는 물론 다른 화면으로 갔다 와도 값이 남습니다. 이게 부담스러우면 상태는 Lua 모듈 쪽(최상위 local)에 두고 C 함수는 순수 계산만 하게 짜세요.

Lua에서 사용하기

컴파일된 .so는 평범한 Lua 모듈과 똑같이 require()로 불러옵니다. C였다는 흔적은 여기서부터 전혀 남지 않습니다 — disk_free.gb(path)는 Lua로 작성한 다른 함수와 호출 문법이 동일합니다.

6manual-2/lua/lua_step6_c_library.lua — Lua에서 사용

require()로 불러오면 그 뒤로는 평범한 Lua 함수처럼 씁니다.

-- plugins/*.so는 require()가 자동으로 찾는다
-- (lua_conf.c가 package.cpath에 './plugins/?.so'를 미리 넣어둔다)
local disk_free = require("disk_free")

local PATHS = { "/", "/tmp", "/var" }

function M.render(p)
    local rows = {}
    for _, path in ipairs(PATHS) do
        local gb = disk_free.gb(path)
        table.insert(rows, {
            name = path,
            value = gb and string.format("%.1f GB 여유", gb) or "경로 없음",
        })
    end
    tapas.render_kv(p, rows)
end
실행 화면 · disk_free.gb()로 조회한 실제 여유 공간
C 플러그인으로 조회한 경로별 디스크 여유 공간 화면

패턴 B — C가 화면까지 직접 그리기, 그리고 왜 웹에서는 안 보이나

plugins/example_view.c는 tapas.render_*()를 거치지 않고 tui_move()/tui_color()/tui_puts()로 패널에 직접 글자를 씁니다 — 함수 하나로 끝나는 만큼 빠르지만, 아래 코드를 보면 알 수 있듯 TapasPanel의 row/col을 기준으로 좌표를 손수 계산해야 합니다.

plugins/example_view.c — 패턴 B: 직접 그리기

update()/render() 두 함수를 한 모듈로 export — 상태(s_time, s_tick)는 static 전역에 유지

#include "tapas_plugin.h"
#include <time.h>

static char s_time[32];
static int  s_tick = 0;

static int l_update(lua_State *L) {
    (void)L;
    time_t t = time(NULL);
    strftime(s_time, sizeof(s_time), "%Y-%m-%d %H:%M:%S", localtime(&t));
    s_tick++;
    return 0;
}

static int l_render(lua_State *L) {
    TapasPanel p = tapas_panel_from_lua(L, 1);

    tui_move(p.row + 1, p.col + 2);
    tui_color(TAPAS_CLR_HEADER);
    tui_puts("[ Example Plugin v1.0 ]");
    tui_clear_line();

    tui_move(p.row + 4, p.col + 4);
    tui_color(TAPAS_CLR_NORMAL);
    tui_printf("현재 시각  : %s", s_time);
    tui_clear_line();

    tui_color(TAPAS_CLR_RESET);
    return 0;
}

static const luaL_Reg funcs[] = {
    {"update", l_update}, {"render", l_render}, {NULL, NULL}
};

int luaopen_example_view(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}
SSH 전용인 이유: JSON 변환 경로가 없음 — tapas.render_table()류는 SSH면 ANSI, 웹이면 JSON으로 매번 갈라 그리지만, C 플러그인이 tui_move()/tui_puts()를 직접 부르면 이 분기를 건너뛰어 원시 ANSI만 나갑니다 — 웹은 그 패널의 JSON을 못 받아 빈 화면입니다. 결론: 패턴 B는 SSH 전용 진단 화면에만, 웹에서도 써야 하면 패턴 A(tapas.render_*())로.

세 번째 예시 — msgq_depth.c: 커널이 이미 아는 값은 다시 파싱하지 않는다

scenarios/ab-pipeline의 큐 대기 건수 컬럼이 실제로 쓰는 플러그인입니다. /proc/sysvipc/msg 텍스트를 파싱하는 대신 msgget() + msgctl(IPC_STAT)으로 커널이 이미 추적 중인 msg_qnum을 직접 읽습니다 — disk_free.c와 똑같이 패턴 A(값만 반환)라서 SSH·웹 어느 쪽에서 봐도 동일하게 나옵니다.

plugins/msgq_depth.c — 패턴 A, 실전 사용 중

System V 메시지 큐(msgq)의 쌓인 건수를 msgctl(IPC_STAT)으로 직접 조회

#include "tapas_plugin.h"
#include <sys/ipc.h>
#include <sys/msg.h>

static int l_read(lua_State *L) {
    long key = (long)luaL_checkinteger(L, 1);

    int msqid = msgget((key_t)key, 0);
    if (msqid < 0) { lua_pushnil(L); return 1; }

    struct msqid_ds ds;
    if (msgctl(msqid, IPC_STAT, &ds) != 0) { lua_pushnil(L); return 1; }

    lua_pushinteger(L, (lua_Integer)ds.msg_qnum);
    return 1;
}

static const luaL_Reg funcs[] = { {"read", l_read}, {NULL, NULL} };

int luaopen_msgq_depth(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}

scenarios/ab-pipeline/lua/view_ab_queue.lua는 파이프라인 하나당 SHM(tapas.shmstruct_read) + 파일(io.open) + msgq_depth.read(pl.msgq_key) 세 소스를 한 행에 조합해서 표로 보여줍니다 — 소스가 SHM이든 파일이든 C 플러그인이든, Lua 쪽에서 보면 그냥 "값 하나 돌려주는 함수" 셋을 조합하는 것과 다르지 않습니다.

컴파일은 한 줄: plugins/build.sh disk_free
  • 탑레벨 Makefile·전체 재빌드 불필요 — include/ + third_party/lua-5.4.7/src 헤더만 있으면 됨(고객 배포판에도 그대로 포함).
  • gcc -shared -fPIC로 만든 .so가 tui_* 심볼을 링크 단계에서 몰라도 되는 건, tapas-eye 바이너리가 -rdynamic으로 자기 심볼을 미리 내보내서 dlopen() 시점에 자동 연결되기 때문 — "링크 없는 링크".
  • require()가 package.cpath로 disk_free.so를 자동으로 찾아서, config.lua나 다른 Lua 파일은 건드릴 필요 없음.

여기 나온 코드, 지금 실제로 돌고 있습니다

manual-1·manual-2 전체가 SSH TUI와 웹 브라우저 양쪽에서 라이브로 동작합니다. Ctrl+O를 누르면 지금 이 페이지의 코드가 그대로 팝업으로 뜹니다.

실제 화면 보러 가기